Architecture
System Overview
Section titled “System Overview”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 │└──────────────────────────────────────────────────────┘Monorepo Layout
Section titled “Monorepo Layout”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 pipelinesDeployed URLs
Section titled “Deployed URLs”| Service | Preview | Production |
|---|---|---|
| Portal | preview.contractorhub-portal.pages.dev | contractorhub-portal.pages.dev |
| API Worker | contractorhub-api-preview.lsdev.workers.dev | contractorhub-api.lsdev.workers.dev |
| Intake Form | preview.construction-leads.pages.dev | construction-leads.pages.dev |
| Docs | contractorhub-docs.pages.dev | docs.contractorhub-portal.pages.dev |
Local Dev Ports
Section titled “Local Dev Ports”| Service | Port | Command |
|---|---|---|
| Portal (Vite) | 8200 | pnpm dev in apps/project-work-portal/ |
| API (Wrangler) | 8201 | pnpm dev in apps/api/ |
| Intake Form (Astro) | 8202 | pnpm dev in lead-intake-form-pwa/ |
| Docs (Starlight) | 8203 | pnpm 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).
Request Flow
Section titled “Request Flow”Portal → API (local dev)
Section titled “Portal → API (local dev)”Browser → localhost:8200/api/v1/leads → Vite dev proxy → localhost:8201/api/v1/leads → Wrangler dev → API Worker → D1 (local)Portal → API (deployed)
Section titled “Portal → API (deployed)”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)Portal path builder (code)
Section titled “Portal path builder (code)”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_URLenv var overrides both
Lead Generation Pipeline
Section titled “Lead Generation Pipeline”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:
| Environment | Value |
|---|---|
| Local dev | http://localhost:8202 |
| Preview | https://preview.construction-leads.pages.dev |
| Production | https://construction-leads.pages.dev |
Authentication
Section titled “Authentication”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}inusers.password_hash
JWT Flow:
POST /api/v1/auth/login→ validates credentials → returns JWT signed withJWT_SIGNING_KEY- Portal stores token in
localStorageascontractorhub_token - All API requests include
Authorization: Bearer {token}header - API middleware verifies signature and expiry via
joselibrary - JWT payload:
{ sub: userId, tenantId, email, role, name, iat, exp }
Roles: admin, member, viewer — enforced by requireRole() middleware per route.
Multi-Tenancy
Section titled “Multi-Tenancy”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).
Database
Section titled “Database”Engine: Cloudflare D1 (SQLite at the edge)
ORM: Drizzle ORM with schema definitions in packages/shared/src/db/
| Environment | D1 Database | Database ID |
|---|---|---|
| Local dev | contractorhub-db | local-dev-db |
| Preview | contractorhub-db-preview | da58538e-c113-4946-9176-1be0eb7d9791 |
| Production | contractorhub-db | 391dce69-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)
API Routes
Section titled “API Routes”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
Geocoding
Section titled “Geocoding”- Provider: Nominatim (OpenStreetMap) — no API key required
- Trigger: Auto-geocodes on lead/customer create and update
- On-demand:
POST /api/v1/geocodefor manual geocoding - Distance: Haversine formula (
haversineDistance()in packages/shared) - Backfill:
node apps/api/scripts/backfill-geocodes.mjsfor existing records - UI: Leaflet maps on lead/customer detail pages with property pin, office pin, and distance polyline
Storage Adapters
Section titled “Storage Adapters”Image uploads use an adapter pattern selected by environment:
| Adapter | Environment | Backend |
|---|---|---|
BrowserStorageAdapter | dev, preview | IndexedDB (local, offline-capable) |
ApiStorageAdapter | production | POST /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.
CI/CD Workflows
Section titled “CI/CD Workflows”| Workflow | File | Trigger |
|---|---|---|
| API Deploy | ci-api-deploy.yml | Push to main/dev branch (apps/api/, packages/shared/) |
| Portal Deploy | ci-portal-deploy.yml | Push to main/dev branch (apps/project-work-portal/, packages/shared/) |
| Intake Deploy | ci-intake-deploy.yml | Push to main/dev branch (lead-intake-form-pwa/, packages/shared/) |
| Test Suite | test-suite.yml | Pull requests, manual dispatch |
| Full Stack Deploy | deploy-stack.yml | Manual dispatch (select environment + components) |
Deploy pipeline: preview deploy → login smoke test → health check → (manual gate) → production deploy.
Environment Configuration
Section titled “Environment Configuration”All API env vars per environment (from wrangler.toml):
| Variable | Local | Preview | Production |
|---|---|---|---|
ENVIRONMENT | development | preview | production |
JWT_SIGNING_KEY | dev-only-placeholder... | preview-only-placeholder... | Secret (via wrangler secret) |
ALLOW_REGISTRATION | true | true | false |
INTAKE_FORM_URL | http://localhost:8202 | https://preview.construction-leads.pages.dev | https://construction-leads.pages.dev |