Skip to content

Architecture

Contractor Portal is a multi-tenant contractor management platform deployed entirely on Cloudflare’s edge network.

┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
│ Project Work │ │ Lead Intake Form │ │ Docs Site │
│ Portal (React) │ │ (Astro SSR) │ │ (Astro Starlight) │
│ CF Pages │ │ CF Pages │ │ CF Pages │
│ :8200 local │ │ :8202 local │ │ :8203 local │
└────────┬────────────┘ └──────────┬───────────┘ └─────────────────────┘
│ /api/v1/* proxy (Vite dev) / CF Pages Function (prod) │ webhook POST
▼ ▼
┌──────────────────────────────────────────────────────┐
│ API Worker (CF Workers) │
│ :8201 local (wrangler dev) │
│ Router → Auth Middleware → Handlers │
└────────────────────────┬─────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ Cloudflare D1 (SQLite) │
│ Drizzle ORM │
└──────────────────────────────────────────────────────┘
construction/
├── apps/
│ ├── api/ # CF Workers API (wrangler)
│ ├── project-work-portal/ # React portal (Vite + Tailwind v4)
│ └── docs/ # This documentation site
├── lead-intake-form-pwa/ # Astro intake form + contractor inspection
├── packages/
│ └── shared/ # Types, schemas, utils shared by all apps
│ └── src/
│ ├── schemas/ # Zod: lead, customer, project, estimate, auth, activity, tenant
│ ├── constants/ # Enums, query keys for React Query
│ ├── scoring/ # Lead scoring engine (30+ rules, temperature thresholds)
│ ├── utils/ # normalizePhone(), haversineDistance(), geocodeAddress()
│ ├── i18n/ # String table, displayValue(), locale management
│ └── config/ # Branding, company, theme, feature flag schemas
├── config/ # Global config (tenant settings)
├── contracts/ # API_CONTRACT.md
├── docs/plans/ # Implementation plans
└── .github/workflows/ # CI/CD pipelines
ServicePreviewProduction
Portalpreview.contractorhub-portal.pages.devcontractorhub-portal.pages.dev
API Workercontractorhub-api-preview.lsdev.workers.devcontractorhub-api.lsdev.workers.dev
Intake Formpreview.construction-leads.pages.devconstruction-leads.pages.dev
Docscontractorhub-docs.pages.devdocs.contractorhub-portal.pages.dev
ServicePortCommand
Portal (Vite)8200pnpm dev in apps/project-work-portal/
API (Wrangler)8201pnpm dev in apps/api/
Intake Form (Astro)8202pnpm dev in lead-intake-form-pwa/
Docs (Starlight)8203pnpm dev in apps/docs/

The portal proxies /api/v1/* requests to localhost:8201 via Vite’s dev proxy. The prefix is defined centrally in packages/shared (SERVICE_PREFIXES.api).

Browser → localhost:8200/api/v1/leads
→ Vite dev proxy → localhost:8201/api/v1/leads
→ Wrangler dev → API Worker → D1 (local)
Browser → contractorhub-portal.pages.dev/api/v1/leads
→ CF Pages Function (functions/api/v1/[[path]].ts)
→ fetch("https://contractorhub-api.lsdev.workers.dev/api/v1/leads")
→ API Worker → D1 (remote)

All portal code uses centralized path builders instead of hardcoded URIs:

import { apiPath } from '@contractorhub/shared';
// apiPath('/leads') → '/api/v1/leads'
// apiPath('/leads/123') → '/api/v1/leads/123'
const response = await apiClient.get(apiPath('/leads'));

The catch-all Pages Function at functions/api/v1/[[path]].ts selects the API target based on branch:

  • CF_PAGES_BRANCH === 'main' → production API worker
  • All other branches → preview API worker
  • API_BASE_URL env var overrides both
Portal DevTools (Settings page)
│ POST /api/v1/admin/generate-leads { count: 10, tenant_id, api_url }
API Worker
│ Forwards to INTAKE_FORM_URL/api/generate-leads
Intake Form (/api/generate-leads endpoint)
│ Generates N fake leads with generateFakeLeadPayload()
│ For each lead: POST {api_url}/api/v1/webhooks/intake
API Worker (/api/v1/webhooks/intake)
│ Creates lead in D1 with scoring via evaluateLead()
D1 Database (leads table)

Environment config for INTAKE_FORM_URL:

EnvironmentValue
Local devhttp://localhost:8202
Previewhttps://preview.construction-leads.pages.dev
Productionhttps://construction-leads.pages.dev

Algorithm: PBKDF2-SHA256 via Web Crypto API (CF Workers compatible, no native modules)

  • 100,000 iterations, 32-byte salt, 32-byte key
  • Stored as {saltHex}:{hashHex} in users.password_hash

JWT Flow:

  1. POST /api/v1/auth/login → validates credentials → returns JWT signed with JWT_SIGNING_KEY
  2. Portal stores token in localStorage as contractorhub_token
  3. All API requests include Authorization: Bearer {token} header
  4. API middleware verifies signature and expiry via jose library
  5. JWT payload: { sub: userId, tenantId, email, role, name, iat, exp }

Roles: admin, member, viewer — enforced by requireRole() middleware per route.

All tables include a tenant_id column. The API middleware extracts tenantId from the JWT and injects it into the request context. Every query is scoped:

SELECT * FROM leads WHERE tenant_id = ? AND ...

Tenant config (company name, phone, email, office address) is stored in the tenants table and editable via PATCH /api/v1/tenant (admin-only).

Engine: Cloudflare D1 (SQLite at the edge) ORM: Drizzle ORM with schema definitions in packages/shared/src/db/

EnvironmentD1 DatabaseDatabase ID
Local devcontractorhub-dblocal-dev-db
Previewcontractorhub-db-previewda58538e-c113-4946-9176-1be0eb7d9791
Productioncontractorhub-db391dce69-2fd3-4a90-bb43-6f9a2de1b331

Migrations are in apps/api/migrations/ (11 migrations). Seed data: apps/api/scripts/seed-account.mjs.

Dev test account: suadmin@dev.local (password set via DEFAULT_SUADMIN_PASSWORD env var)

See API Overview for the full endpoint reference. Key route groups:

  • Auth — register, login, change-password, contractor-token
  • Leads — CRUD, convert-to-customer
  • Customers — CRUD
  • Projects — CRUD
  • Estimates — CRUD, duplicate, clone, renew, share, archive, convert-to-project, preview, PDF
  • Attachments — upload, list, delete, download (auth + public)
  • Catalog — items + unit types (feature-gated)
  • Activities — CRUD, recent feed
  • Admin — generate data, delete data, user management, system info
  • Provider: Nominatim (OpenStreetMap) — no API key required
  • Trigger: Auto-geocodes on lead/customer create and update
  • On-demand: POST /api/v1/geocode for manual geocoding
  • Distance: Haversine formula (haversineDistance() in packages/shared)
  • Backfill: node apps/api/scripts/backfill-geocodes.mjs for existing records
  • UI: Leaflet maps on lead/customer detail pages with property pin, office pin, and distance polyline

Image uploads use an adapter pattern selected by environment:

AdapterEnvironmentBackend
BrowserStorageAdapterdev, previewIndexedDB (local, offline-capable)
ApiStorageAdapterproductionPOST /api/v1/uploads → R2/S3

Override with VITE_STORAGE_ADAPTER=browser or VITE_STORAGE_ADAPTER=api.

Stored URLs use /_local/{path} scheme for IndexedDB, resolved at render time via useResolvedHtml() hook.

WorkflowFileTrigger
API Deployci-api-deploy.ymlPush to main/dev branch (apps/api/, packages/shared/)
Portal Deployci-portal-deploy.ymlPush to main/dev branch (apps/project-work-portal/, packages/shared/)
Intake Deployci-intake-deploy.ymlPush to main/dev branch (lead-intake-form-pwa/, packages/shared/)
Test Suitetest-suite.ymlPull requests, manual dispatch
Full Stack Deploydeploy-stack.ymlManual dispatch (select environment + components)

Deploy pipeline: preview deploy → login smoke test → health check → (manual gate) → production deploy.

All API env vars per environment (from wrangler.toml):

VariableLocalPreviewProduction
ENVIRONMENTdevelopmentpreviewproduction
JWT_SIGNING_KEYdev-only-placeholder...preview-only-placeholder...Secret (via wrangler secret)
ALLOW_REGISTRATIONtruetruefalse
INTAKE_FORM_URLhttp://localhost:8202https://preview.construction-leads.pages.devhttps://construction-leads.pages.dev