BuildRight Contractors Portal
Dev Reference
Architecture, schema, API routes, file structure, and deployment notes for engineers
Last updated: 2026-07-12

Stack

LayerTechnology
FrontendReact 18 + TypeScript + Vite 5
HostingCloudflare Pages (buildright-portal)
API / BackendCloudflare Worker (edge runtime)
DatabaseCloudflare D1 SQLite (contractor_db)
AuthPBKDF2-SHA256 + JWT HS256
Build toolVite (frontend) + esbuild via Wrangler (worker)

Routing

The frontend uses hash-based routing (React state, no React Router):

HashRenders
# (empty)Public site (hero, services, contact, quote form)
#loginAdmin login form
#employee-loginEmployee login form (name + PIN)
#dashboard (and others)Admin portal — specific module
#teamAdmin portal — Team/Employees module

Sign out reloads the page (window.location.reload()), which drops hash state and returns to the public site.

Key Files

PathPurposeLines
worker/index.tsCloudflare Worker — all API route handlers~462
src/components/PublicSite.tsxPublic-facing landing page and quote form~198
src/components/AdminApp.tsxAdmin portal shell and sidebar navigation~209
src/App.tsxRoot component — hash-based routing logic
migrations/0001_init.sqlInitial schema — all tables including employees
wrangler.tomlWorker config — D1 binding, env name, routes

Database Schema

All tables are in Cloudflare D1 (contractor_db, UUID: 5f5efd19-3979-4c1b-a75c-ebd9e2256736). Results are flat — no nested joins.

settings

CREATE TABLE settings (
  key   TEXT PRIMARY KEY,
  value TEXT
);

Key rows:

keyvalue format
business_namePlain text
business_phonePlain text
business_emailPlain text
business_addressPlain text
service_typesJSON array of strings
admin_password_hashPBKDF2 hex hash
business_logoBase64 data URL (large payload, no external file storage needed)

clients

CREATE TABLE clients (
  id         INTEGER PRIMARY KEY,
  name       TEXT NOT NULL,
  email      TEXT,
  phone      TEXT,
  address    TEXT,
  created_at TEXT DEFAULT CURRENT_TIMESTAMP
);

service_requests

CREATE TABLE service_requests (
  id             INTEGER PRIMARY KEY,
  name           TEXT NOT NULL,
  email          TEXT,
  phone          TEXT,
  service_type   TEXT,
  service_address TEXT,
  preferred_date TEXT,
  notes          TEXT,
  status         TEXT DEFAULT 'New',
  created_at     TEXT DEFAULT CURRENT_TIMESTAMP
);

jobs

CREATE TABLE jobs (
  id             INTEGER PRIMARY KEY,
  client_id      INTEGER REFERENCES clients(id),
  service_type   TEXT,
  scheduled_date TEXT,
  scheduled_time TEXT,
  address        TEXT,
  labor_cents    INTEGER DEFAULT 0,
  material_cents INTEGER DEFAULT 0,
  status         TEXT DEFAULT 'Scheduled',
  recurrence     TEXT DEFAULT 'none',
  notes          TEXT,
  created_at     TEXT DEFAULT CURRENT_TIMESTAMP
);

estimates

CREATE TABLE estimates (
  id          INTEGER PRIMARY KEY,
  client_id   INTEGER REFERENCES clients(id),
  line_items  TEXT,   -- JSON array of {description, qty, unit_price}
  total_cents INTEGER DEFAULT 0,
  status      TEXT DEFAULT 'Draft',
  notes       TEXT,
  created_at  TEXT DEFAULT CURRENT_TIMESTAMP
);

invoices

CREATE TABLE invoices (
  id           INTEGER PRIMARY KEY,
  client_id    INTEGER REFERENCES clients(id),
  line_items   TEXT,   -- JSON array of {description, qty, unit_price}
  amount_cents INTEGER DEFAULT 0,
  status       TEXT DEFAULT 'Draft',
  notes        TEXT,
  created_at   TEXT DEFAULT CURRENT_TIMESTAMP
);

appointments

CREATE TABLE appointments (
  id                   INTEGER PRIMARY KEY,
  client_id            INTEGER REFERENCES clients(id),
  service_request_id   INTEGER,
  title                TEXT NOT NULL,
  service_type         TEXT DEFAULT '',
  scheduled_date       TEXT,
  scheduled_time       TEXT DEFAULT '09:00',
  duration_minutes     INTEGER DEFAULT 60,
  address              TEXT DEFAULT '',
  assigned_contractor  TEXT DEFAULT '',
  notes                TEXT DEFAULT '',
  status               TEXT DEFAULT 'Scheduled',
  -- Recurring fields (added via migrations 0006 + 0007):
  recurring            INTEGER DEFAULT 0,           -- 0 = one-off, 1 = recurring
  recurrence           TEXT DEFAULT '',             -- Daily | Weekly | Bi-Weekly | Monthly | Custom Weekdays
  recurring_end_date   TEXT DEFAULT '',             -- YYYY-MM-DD, optional
  recurring_days       TEXT DEFAULT '[]',           -- JSON array of day indices [0=Sun … 6=Sat]
  created_at           TEXT DEFAULT CURRENT_TIMESTAMP,
  updated_at           TEXT DEFAULT CURRENT_TIMESTAMP
);

Recurrence options: Daily, Weekly, Bi-Weekly, Monthly, Custom Weekdays. When "Custom Weekdays" is selected, recurring_days holds a JSON array such as [1,3,5] (Mon, Wed, Fri). The UI renders a weekday button-picker (Sun Mon Tue Wed Thu Fri Sat).

employees

CREATE TABLE employees (
  id         INTEGER PRIMARY KEY,
  name       TEXT NOT NULL,
  phone      TEXT,
  pin_hash   TEXT,
  created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
Note on amounts: estimates uses total_cents; invoices uses amount_cents. jobs has both labor_cents and material_cents. All stored as integers, divide by 100 for UI display.

API Routes

All routes are on the Worker at buildright-api.sonandigital.com. Admin routes require a Authorization: Bearer <jwt> header.

MethodRouteAuthDescription
GET/api/configNonePublic config: business name, phone, email, address, logo (base64)
POST/api/auth/loginNoneAdmin login — returns JWT
POST/api/employee/loginNoneEmployee login (name + PIN) — returns JWT
GET/api/employee/jobsEmployee JWTJobs assigned to logged-in employee
POST/api/service-requestNonePublic quote form submission
GET/api/admin/settingsAdmin JWTGet all settings key-value pairs
PUT/api/admin/settingsAdmin JWTUpdate settings
GET/api/admin/clientsAdmin JWTList all clients
POST/api/admin/clientsAdmin JWTCreate client
PUT/api/admin/clients/:idAdmin JWTUpdate client
DELETE/api/admin/clients/:idAdmin JWTDelete client
GET/api/admin/service-requestsAdmin JWTList all service requests
POST/api/admin/service-requestsAdmin JWTCreate service request (admin-side)
PATCH/api/admin/service-requests/:idAdmin JWTUpdate status
DELETE/api/admin/service-requests/:idAdmin JWTDelete request
GET/api/admin/jobsAdmin JWTList all jobs
POST/api/admin/jobsAdmin JWTCreate job
PUT/api/admin/jobs/:idAdmin JWTUpdate job
DELETE/api/admin/jobs/:idAdmin JWTDelete job
GET/api/admin/estimatesAdmin JWTList all estimates
POST/api/admin/estimatesAdmin JWTCreate estimate
PUT/api/admin/estimates/:idAdmin JWTUpdate estimate
DELETE/api/admin/estimates/:idAdmin JWTDelete estimate
GET/api/admin/invoicesAdmin JWTList all invoices
POST/api/admin/invoicesAdmin JWTCreate invoice
PUT/api/admin/invoices/:idAdmin JWTUpdate invoice
DELETE/api/admin/invoices/:idAdmin JWTDelete invoice
GET/api/admin/appointmentsAdmin JWTList all appointments
POST/api/admin/appointmentsAdmin JWTCreate appointment
PUT/api/admin/appointments/:idAdmin JWTUpdate appointment
DELETE/api/admin/appointments/:idAdmin JWTDelete appointment
GET/api/admin/employeesAdmin JWTList all employees
POST/api/admin/employeesAdmin JWTCreate employee (with PIN hashing)
PUT/api/admin/employees/:idAdmin JWTUpdate employee / reset PIN
DELETE/api/admin/employees/:idAdmin JWTDelete employee

Auth

FieldValue
AlgorithmPBKDF2-SHA256
SaltSONAN_SP_2026
Iterations100,000
JWT algorithmHS256
JWT fallback secretSONAN_BUILDRIGHT_JWT_2026
Default admin passwordAdmin@2026

Employee PINs use the same PBKDF2 hash. PINs are 4 digits. After hashing, the plaintext PIN is never stored.

Known Gotchas

IssueDetail
FUSE filesystem truncates large filesAlways write to /tmp first, hash from /tmp. Never write large files directly to the mounted workspace path.
.git/config corruption on FUSEAfter any AI session, .git/config will likely be corrupted. User must rebuild from Windows terminal before pushing (see CLAUDE.md for exact steps).
npm run build must run from Windows terminalesbuild uses Windows binaries in node_modules — won't run in Linux sandbox.
wrangler deploy must run from Windows terminalThe sandbox environment gets 403 from Cloudflare.
No Supabase — pure D1 SQLAll queries are flat D1 SQL. No nested join results, no arrays inside arrays.
Amounts stored as centslabor_cents, material_cents, total_cents, amount_cents — all integers. Divide by 100 for display. Do not interchange estimate and invoice amount fields.
Logo stored as base64business_logo in the settings table is a base64 data URL. Large payload on /api/config responses but no external file storage is needed.