Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

51 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Yardex — Fence Wholesaler Operations Platform

Design To Inspire, Engineered to Endure.

B2B2C platform that lets fence dealers produce accurate, visually-rendered fence quotations in minutes instead of weeks.

Privacy

Yardex does not store personal contact information of company directors in application code, seeds, or test fixtures. Each dealer's contact info is entered by the dealer themselves.

In practice this means:

  • The seed script creates a demo dealer record with contactPhone = null.
  • The Installer model has optional phone / email columns that are never populated by the seed or by any defaulting logic - the dealer types their own contractor's details in.
  • The PDF generator and public approval pages never echo a company phone number from any hard-coded constant.

If a future feature genuinely needs a "primary contact phone" for a company, it must read from a per-tenant Dealer.phone field, which the dealer fills in for themselves - never from a hard-coded company default.

Goals

  • Reduce quote-to-order cycle from 2–3 weeks to 7 working days.
  • Dealers log in to upload a house layout, draw the fence on it, pick a design, and instantly get a rendered preview and itemised quotation.
  • Customer receives a public approval link with a quote and an e-signature field.
  • All product data, pricing, and templates are managed centrally with per-dealer overrides.

Stack

  • Frontend: Vite + React + TypeScript + Tailwind, served by nginx in production
  • Backend: NestJS + TypeScript, JWT auth, Prisma ORM
  • Database: PostgreSQL 16
  • Storage: ./data mounted as a volume – holds uploaded plans, generated renders, PDFs, signatures, and design overlay assets
  • Container: docker compose

Repo layout

.
├── backend/        NestJS API + Prisma
├── frontend/       Vite/React/Tailwind SPA
├── data/           Persistent uploads & generated assets
│   └── overlays/   Design overlay PNGs (sample assets checked in)
└── docker-compose.yml

Quick start

# 1. Start everything (db + backend + frontend)
docker compose up -d --build

# 2. Apply schema (runs automatically on backend boot) and seed once
cd backend
npm install
DATABASE_URL=postgresql://fence:fence@localhost:5432/fencevisionpro npx prisma migrate deploy
DATABASE_URL=postgresql://fence:fence@localhost:5432/fencevisionpro npx prisma db seed
cd ..

# 3. Open
#    App:  http://localhost:12890
#    API:  http://localhost:12888

The seed script is a one-time bootstrap that needs the TypeScript toolchain (which is on your host, not in the slim production image). It targets the Postgres port 5432 exposed by docker compose on the host loopback.

Seeded logins:

  • Admin (you): admin@yardex.local / admin1234
  • Dealer owner: owner@yardex.local / owner1234

Core flows

Dealer onboarding

  1. Admin logs in, goes to Dealers, onboards a new dealer.
  2. Owner receives login + password, signs in to start creating quotes.

Quote lifecycle

  1. Dealer creates a new quote.
  2. Uploads a floor plan and calibrates the scale (click two reference points, enter real distance).
  3. Draws fence segments on the plan – the total length is computed live.
  4. Picks a design + primary product, and uploads a house photo for the client-side preview. The server also composites a top-down render via the /render endpoint.
  5. Hits Save & send – the system derives line items, computes totals, and emits a public approval link.
  6. Customer opens the link, reviews the render + line items, signs, and approves.
  7. Dealer generates the PDF and ships the order.

Projects

End customers (homeowners) can submit a project (photos, measurements, property address) that the dealer reviews and turns into a quote. The Project workspace has tabs for documents, fence selections, measurements and AI visualisations.

Installations

Once a quote is APPROVED, the dealer schedules an Installation. The installer is assigned from the dealer-owned Installer directory (name, phone, email all entered by the dealer — see the Privacy section). The dealer can issue a public customer link so the homeowner can see the live timeline + photo gallery + sign off on completion.

Invoices

From an APPROVED quote the dealer can generate a DRAFT invoice (INV-<year>-<NNNN> per dealer, line items copied from the quote). Drafts are editable; once sent, the invoice is part of the audit trail and moves through SENT → PAID | VOID via the transition endpoint.

API surface (selected)

Method Path Description
POST /auth/login Email + password → JWT
GET /auth/me Current user
GET /wholesalers Admin only (path kept for backward compat)
POST /wholesalers Admin only – onboard a new dealer
POST /wholesalers/:id/staff Owner adds a staff sub-user (path kept for backward compat)
GET /products Catalog with effective price for current tenant
POST /products Admin – add a product
POST /products/:id/override/:wholesalerId Admin – set a per-dealer price
GET /designs Design library
POST /quotes/upload-floorplan Multipart upload of a plan/photo
POST /render Server-side composite (top-down)
POST /quotes Create quote – derives line items from fence segments
GET /quotes/:id Get a quote
PUT /quotes/:id/status Update status (DRAFT/SENT/APPROVED/...)
GET /quotes/:id/pdf Generate and return the PDF URL
GET /public/quotes/:id Customer-facing view (no auth)
POST /public/quotes/:id/approve Customer e-signature + approval
POST /auth/change-password Self-service password change (requires current password)
POST /wholesalers/:id/staff/:staffId/reset-password Owner resets a staff password
POST /wholesalers/:id/staff/:staffId/deactivate Owner deactivates a staff user
POST /wholesalers/:id/staff/:staffId/reactivate Owner reactivates a staff user
PATCH /quotes/:id Partial update of a DRAFT (or notes/renderUrl on a SENT quote)
DELETE /quotes/:id Delete a DRAFT quote
POST /quotes/:id/clone Clone any quote as a new DRAFT
POST /quotes/:id/snapshot Persist a client-captured 3D frame as the quote's render
POST /quotes/expire-overdue Mark all SENT quotes with past validUntil as EXPIRED (idempotent, also runs every 5 min)
DELETE /products/:id/override/:wholesalerId Admin – clear a per-dealer price override
POST /ai/render-image Photorealistic fence image (server-side)
POST /ai/generate-3d Self-contained three.js scene (LLM-generated)
GET /ai/status Is AI enabled? Which models?
GET /installers List installers for the current dealer (admin sees all)
GET /installers/:id Fetch a single installer
POST /installers Create an installer (dealerId optional for admin)
PATCH /installers/:id Update an installer
DELETE /installers/:id Soft-delete (status → INACTIVE)
GET /invoices List invoices (filter by status / quoteId)
GET /invoices/:id Fetch a single invoice (with line items)
POST /invoices Create a DRAFT invoice from an APPROVED quote
PATCH /invoices/:id Edit DRAFT notes / dueAt
POST /invoices/:id/transition State machine: DRAFT→SENT→PAID|VOID
DELETE /invoices/:id Delete a DRAFT invoice
POST /installations Create an installation (now accepts installerId)

AI features

The backend integrates with an OpenAI-compatible image / chat endpoint to power two extra visualisation features. The credentials live in backend/.env (gitignored) - see backend/.env.example for the template.

Env var Default Purpose
AI_ENABLED true Master switch
AI_BASE_URL (empty) OpenAI-compatible base URL (e.g. http://host:port/v1)
AI_API_KEY (empty) Bearer token for the AI service
AI_IMAGE_MODEL z-image-turbo Model for /ai/render-image
AI_CODE_MODEL mimo-v25-pro Model for /ai/generate-3d
AI_IMAGE_SIZE 1024x1024 Output size for image gen
AI_IMAGE_STEPS 9 Inference steps for image gen

Endpoints

  • GET /ai/status - returns whether AI is enabled and the model names
  • POST /ai/render-image - body { style, color, heightFt, surroundings?, quoteId?, lineItemIndex?, overview? } -> { url, aiImageUrls?, aiOverviewImageUrl? }. When quoteId is set, the URL is persisted onto the quote: at aiImageUrls[lineItemIndex] (per-line-item), at aiOverviewImageUrl (when overview: true), or appended to aiImageUrls (legacy).
  • POST /ai/generate-3d - body { style, color, heightFt, panelCount?, gateCount?, quoteId? } -> { code, model }. When quoteId is set, the code is also persisted onto quote.threeJsCode so it survives a page refresh.
  • POST /ai/analyse-photo - multipart file plus optional quoteId form field. Uploads the photo to /static/uploads/, runs the vision model, and (when quoteId is set) appends the result to quote.photoAnalyses.
  • POST /ai/analyse-photo-url - body { imageUrl, quoteId? }. Re-analyses an already-uploaded image and (when quoteId is set) appends the result to quote.photoAnalyses.
  • GET /ai/quote/:quoteId/photo-analyses - returns the gallery of analyses for a quote.

Where the AI shows up in the UI

  • NewQuotePage - "Design preview" section gains two buttons: "✨ AI render image" and "🧊 Generate 3D scene". The AI image is automatically used as the quote's preview when the user saves.
  • QuoteDetailPage - "AI visualisation" section lets the dealer re-run the AI at any time. Two separate flows:
    • Whole-quote render: the original button writes the URL onto quote.renderUrl and is shown in the "Rendered preview" card.
    • Per-item renders: a "Per-item AI renders" section shows a card for each line item with its own Generate / Regenerate button. URLs are saved to quote.aiImageUrls[i] in order, so the customer-facing PDF shows one thumbnail per item, and the views survive a page refresh. A "✨ Generate all items" button walks every line item in sequence.
    • 3D scene: a "🎮 View 3D scene" button opens the saved quote.threeJsCode in a modal with the sandboxed ThreeJsViewer. The viewer toolbar has Reset view, Reset zoom, Auto-orbit and Rerun controls.
  • ProjectDetailPage - each AI_3D_SNAPSHOT tile has a "🎮 View 3D" button that opens the saved viz.sourceCode in the ThreeJsViewer modal.

Security

The three.js code is generated by an LLM and is therefore untrusted. The frontend renders it inside a sandboxed iframe (sandbox="allow-scripts" with no allow-same-origin) so the generated code:

  • cannot access the host page's DOM, localStorage, or cookies
  • cannot make authenticated requests back to our API
  • cannot navigate the parent window

THREE is loaded from a CDN inside the iframe (not in the host page), so the only global the generated code can see is THREE itself.

The image generation runs server-side and writes the result into ./data/renders/, which is served back to the browser as a static asset. The API key never reaches the client.

Public PDFs

Customer-shareable PDFs (quotation and invoice) are written to <DATA_DIR>/public/pdfs/ and served via the unauthenticated /public/pdfs/:filename route (see PublicAssetsController). The older /static/pdfs/:filename route is still JWT-gated for backward compat with old drafts. To expose /public/ through the public 12883 vhost, add the location block in deploy/nginx/yardex-12883-public-snippet.conf to the existing nginx server config and reload nginx.

Deployment

This is a standard docker compose stack that runs anywhere Docker 20+ does. The image builds are reproducible; secrets live in backend/.env (gitignored) and are passed in at runtime via env_file - they are never baked into the image.

1. One-time setup on the server

# Install Docker + compose plugin (Ubuntu/Debian)
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER   # log out and back in

# Clone the repo
git clone <your-git-url> yardex
cd yardex

# Create the real env file
cp backend/.env.example backend/.env
$EDITOR backend/.env          # set DATABASE_URL, JWT_SECRET, AI_BASE_URL, AI_API_KEY, ...
chmod 600 backend/.env        # protect the API key from other users

The only env vars you must set:

Variable Why
JWT_SECRET Random 64-char string - openssl rand -hex 32
AI_BASE_URL Your OpenAI-compatible image / chat endpoint
AI_API_KEY Bearer token for that endpoint
(other AI_* vars) Defaults are fine; tweak only if your model differs

The seed script (prisma/seed.ts) must run once on the host because the production image has no TypeScript toolchain. Run it after the first docker compose up -d:

cd backend
npm install
DATABASE_URL=postgresql://fence:fence@localhost:5432/fencevisionpro npx prisma migrate deploy
DATABASE_URL=postgresql://fence:fence@localhost:5432/fencevisionpro npx prisma db seed
cd ..

2. Start the stack

docker compose up -d --build
docker compose ps      # all three services should be 'Up' / 'healthy'

By default the stack listens on:

  • http://<server>:12888 - backend (NestJS API)
  • http://<server>:12889 - frontend (Vite SPA served by nginx)
  • localhost:5432 - Postgres (only bound to loopback; change in compose if you need remote psql)

3. Put it behind HTTPS (recommended)

The simplest path is Caddy as a reverse proxy - automatic Let's Encrypt certificates, zero config. Add a Caddyfile to the repo:

app.yardex.com.my {
    reverse_proxy localhost:12889
}

api.yardex.com.my {
    reverse_proxy localhost:12888
}

Then on the server:

sudo apt install -y caddy
sudo cp Caddyfile /etc/caddy/Caddyfile
sudo systemctl reload caddy

Point your DNS A records at the server and Caddy will fetch certs automatically. Open https://app.yardex.com.my in a browser.

If you'd rather stay on the compose-managed ports, change the host-side port mapping in docker-compose.yml to 80:80 and 443:3000 and run nginx in front - but Caddy is a lot less ceremony.

4. Backups

Two things need to be backed up:

# Postgres database
docker exec yardex_db pg_dump -U fence fencevisionpro | gzip > backup-$(date +%F).sql.gz

# Uploaded plans / generated renders / PDFs / signatures
tar -czf data-$(date +%F).tar.gz data/uploads data/renders data/pdfs data/signatures

Schedule both with cron (daily is plenty for a v1). Restore with docker exec -i yardex_db psql -U fence fencevisionpro < backup.sql.gz and unpacking the tarball.

5. Updating

git pull
docker compose build
docker compose up -d       # picks up new images, runs migrations

Rolling back: git checkout <prev-tag> && docker compose up -d --build.

6. Where to host

This stack is small and runs fine on a $5-10/month VPS (1 vCPU, 1-2 GB RAM is plenty for the v1 workload). Tested concepts:

  • Hetzner (CX22 - €4.5/mo) - cheapest, EU/US
  • DigitalOcean (Basic Droplet - $6/mo) - simple, US/EU/SG
  • AWS Lightsail ($5/mo) - if you're already in AWS
  • Vultr, Linode - similar tier

Pick a region close to your dealers for the lowest latency (Ashburn, NY or SFO are good choices).

Architectural seams (where to extend later)

The MVP is intentionally simple in three places that are obvious upgrade paths:

  1. Floor plan → fence segments

    • v1: interactive canvas (calibrate + click-to-draw).
    • Swap point: PlanEditor.tsx + a new POST /quotes/auto-detect endpoint if you add an AI model later.
  2. Design preview (rendered image)

    • v1: client-side <canvas> composite + server-side top-down sharp composite.
    • Swap point: DesignPreview.tsx (client) and RenderService.compositeTopDown (server). Both can be replaced with a 3D pipeline (Three.js / Blender) or an external AI image service without touching the rest of the code.
  3. Customer approval

    • v1: signed URL (UUID) + canvas signature.
    • Swap point: add signed-expiry tokens, or integrate DocuSign/HelloSign for stronger legal weight.

Development (without Docker)

# Backend
cd backend
cp .env.example .env
npm install
npx prisma migrate dev
npx prisma db seed
npm run start:dev

# Frontend (in another terminal)
cd frontend
cp .env.example .env
npm install
npm run dev

The Vite dev server proxies /api and /static to http://localhost:12888.

Data model (summary)

  • Dealer (tenant)
  • User (ADMIN, WHOLESALER_OWNER, WHOLESALER_STAFF) – staff are scoped to a dealer
  • Product – global catalog with optional PriceOverride per dealer
  • Design – name, style, overlay URL, config; linked to Products via DesignProduct (coverage in meters)
  • QuoteTemplate – per-dealer header/footer/terms
  • Quote – customer info, fence segments (in meters), selected design, totals, status
  • QuoteLineItem – derived from segments + product pricing

Public AI Yard Visualizer

A public, unauthenticated page (/ai-generate) that lets any visitor upload (or pick from a curated gallery) a yard photo, choose front- or back-yard, and get a Yardex-rendered fence preview powered by the existing AiService (AI_IMAGE_MODEL, AI_VISION_MODEL — no new provider integration). The submission creates a PublicLead row that sales reps can pick up from the authenticated /leads page and convert into a draft Quote with one click.

Endpoints (all under /api)

Method Path Auth Notes
GET /public/ai-generation/config none Gallery photos + style list
POST /public/ai-generation none, rate-limited (5 / IP / hr) Multipart (upload) OR JSON (gallery)
GET /public/ai-generation/:id/status none Polled by the result page
GET /public/ai-generation/:id/result none Full public-safe lead record
GET /admin/leads JWT Paginated list, status / date filters
GET /admin/leads/:id JWT Full lead detail (incl. notes)
POST /admin/leads/:id/convert-to-quote JWT Creates a DRAFT Quote and links it
POST /admin/leads/:id/mark-contacted JWT Sets contactedAt, optional notes
POST /admin/leads/:id/archive JWT Soft-archive (archivedAt)

Gallery assets

Six curated stock photos live under data/gallery/ and are served from /static/gallery/<id>.jpg. They are git-ignored (the directory is rebuilt on first run from a tiny sharp-generated placeholder so the page always returns 6 items in fresh environments).

Roadmap

  • Replace canvas drawing with auto-detect via a CV/ML model
  • Replace 2D preview with a 3D / AI renderer
  • Dealer template editor (logo, accent color, terms)
  • Email/SMS delivery of approval links
  • Multi-currency support
  • Inventory / lead-time integration

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages