Design To Inspire, Engineered to Endure.
B2B2C platform that lets fence dealers produce accurate, visually-rendered fence quotations in minutes instead of weeks.
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
Installermodel has optionalphone/emailcolumns 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.
- 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.
- Frontend: Vite + React + TypeScript + Tailwind, served by nginx in production
- Backend: NestJS + TypeScript, JWT auth, Prisma ORM
- Database: PostgreSQL 16
- Storage:
./datamounted as a volume – holds uploaded plans, generated renders, PDFs, signatures, and design overlay assets - Container: docker compose
.
├── 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
# 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:12888The 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
5432exposed by docker compose on the host loopback.
Seeded logins:
- Admin (you):
admin@yardex.local/admin1234 - Dealer owner:
owner@yardex.local/owner1234
- Admin logs in, goes to Dealers, onboards a new dealer.
- Owner receives login + password, signs in to start creating quotes.
- Dealer creates a new quote.
- Uploads a floor plan and calibrates the scale (click two reference points, enter real distance).
- Draws fence segments on the plan – the total length is computed live.
- 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
/renderendpoint. - Hits Save & send – the system derives line items, computes totals, and emits a public approval link.
- Customer opens the link, reviews the render + line items, signs, and approves.
- Dealer generates the PDF and ships the order.
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.
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.
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.
| 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) |
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 |
GET /ai/status- returns whether AI is enabled and the model namesPOST /ai/render-image- body{ style, color, heightFt, surroundings?, quoteId?, lineItemIndex?, overview? }->{ url, aiImageUrls?, aiOverviewImageUrl? }. WhenquoteIdis set, the URL is persisted onto the quote: ataiImageUrls[lineItemIndex](per-line-item), ataiOverviewImageUrl(whenoverview: true), or appended toaiImageUrls(legacy).POST /ai/generate-3d- body{ style, color, heightFt, panelCount?, gateCount?, quoteId? }->{ code, model }. WhenquoteIdis set, the code is also persisted ontoquote.threeJsCodeso it survives a page refresh.POST /ai/analyse-photo- multipartfileplus optionalquoteIdform field. Uploads the photo to/static/uploads/, runs the vision model, and (whenquoteIdis set) appends the result toquote.photoAnalyses.POST /ai/analyse-photo-url- body{ imageUrl, quoteId? }. Re-analyses an already-uploaded image and (whenquoteIdis set) appends the result toquote.photoAnalyses.GET /ai/quote/:quoteId/photo-analyses- returns the gallery of analyses for a quote.
- 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.renderUrland 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.threeJsCodein a modal with the sandboxed ThreeJsViewer. The viewer toolbar has Reset view, Reset zoom, Auto-orbit and Rerun controls.
- Whole-quote render: the original button writes the URL onto
- ProjectDetailPage - each
AI_3D_SNAPSHOTtile has a "🎮 View 3D" button that opens the savedviz.sourceCodein the ThreeJsViewer modal.
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.
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.
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.
# 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 usersThe 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 firstdocker 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 ..
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)
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 caddyPoint 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.
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/signaturesSchedule 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.
git pull
docker compose build
docker compose up -d # picks up new images, runs migrationsRolling back: git checkout <prev-tag> && docker compose up -d --build.
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).
The MVP is intentionally simple in three places that are obvious upgrade paths:
-
Floor plan → fence segments
- v1: interactive canvas (calibrate + click-to-draw).
- Swap point:
PlanEditor.tsx+ a newPOST /quotes/auto-detectendpoint if you add an AI model later.
-
Design preview (rendered image)
- v1: client-side
<canvas>composite + server-side top-downsharpcomposite. - Swap point:
DesignPreview.tsx(client) andRenderService.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.
- v1: client-side
-
Customer approval
- v1: signed URL (UUID) + canvas signature.
- Swap point: add signed-expiry tokens, or integrate DocuSign/HelloSign for stronger legal weight.
# 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 devThe Vite dev server proxies /api and /static to http://localhost:12888.
Dealer(tenant)User(ADMIN, WHOLESALER_OWNER, WHOLESALER_STAFF) – staff are scoped to a dealerProduct– global catalog with optionalPriceOverrideper dealerDesign– name, style, overlay URL, config; linked toProducts viaDesignProduct(coverage in meters)QuoteTemplate– per-dealer header/footer/termsQuote– customer info, fence segments (in meters), selected design, totals, statusQuoteLineItem– derived from segments + product pricing
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.
| 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) |
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).
- 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