Stack
| Layer | Technology |
|---|---|
| Frontend | React 18 + TypeScript + Vite 5 |
| Hosting | Cloudflare Pages (buildright-portal) |
| API / Backend | Cloudflare Worker (edge runtime) |
| Database | Cloudflare D1 SQLite (contractor_db) |
| Auth | PBKDF2-SHA256 + JWT HS256 |
| Build tool | Vite (frontend) + esbuild via Wrangler (worker) |
Routing
The frontend uses hash-based routing (React state, no React Router):
| Hash | Renders |
|---|---|
# (empty) | Public site (hero, services, contact, quote form) |
#login | Admin login form |
#employee-login | Employee login form (name + PIN) |
#dashboard (and others) | Admin portal — specific module |
#team | Admin portal — Team/Employees module |
Sign out reloads the page (window.location.reload()), which drops hash state and returns to the public site.
Key Files
| Path | Purpose | Lines |
|---|---|---|
worker/index.ts | Cloudflare Worker — all API route handlers | ~462 |
src/components/PublicSite.tsx | Public-facing landing page and quote form | ~198 |
src/components/AdminApp.tsx | Admin portal shell and sidebar navigation | ~209 |
src/App.tsx | Root component — hash-based routing logic | — |
migrations/0001_init.sql | Initial schema — all tables including employees | — |
wrangler.toml | Worker 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:
| key | value format |
|---|---|
business_name | Plain text |
business_phone | Plain text |
business_email | Plain text |
business_address | Plain text |
service_types | JSON array of strings |
admin_password_hash | PBKDF2 hex hash |
business_logo | Base64 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
);
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.
| Method | Route | Auth | Description |
|---|---|---|---|
| GET | /api/config | None | Public config: business name, phone, email, address, logo (base64) |
| POST | /api/auth/login | None | Admin login — returns JWT |
| POST | /api/employee/login | None | Employee login (name + PIN) — returns JWT |
| GET | /api/employee/jobs | Employee JWT | Jobs assigned to logged-in employee |
| POST | /api/service-request | None | Public quote form submission |
| GET | /api/admin/settings | Admin JWT | Get all settings key-value pairs |
| PUT | /api/admin/settings | Admin JWT | Update settings |
| GET | /api/admin/clients | Admin JWT | List all clients |
| POST | /api/admin/clients | Admin JWT | Create client |
| PUT | /api/admin/clients/:id | Admin JWT | Update client |
| DELETE | /api/admin/clients/:id | Admin JWT | Delete client |
| GET | /api/admin/service-requests | Admin JWT | List all service requests |
| POST | /api/admin/service-requests | Admin JWT | Create service request (admin-side) |
| PATCH | /api/admin/service-requests/:id | Admin JWT | Update status |
| DELETE | /api/admin/service-requests/:id | Admin JWT | Delete request |
| GET | /api/admin/jobs | Admin JWT | List all jobs |
| POST | /api/admin/jobs | Admin JWT | Create job |
| PUT | /api/admin/jobs/:id | Admin JWT | Update job |
| DELETE | /api/admin/jobs/:id | Admin JWT | Delete job |
| GET | /api/admin/estimates | Admin JWT | List all estimates |
| POST | /api/admin/estimates | Admin JWT | Create estimate |
| PUT | /api/admin/estimates/:id | Admin JWT | Update estimate |
| DELETE | /api/admin/estimates/:id | Admin JWT | Delete estimate |
| GET | /api/admin/invoices | Admin JWT | List all invoices |
| POST | /api/admin/invoices | Admin JWT | Create invoice |
| PUT | /api/admin/invoices/:id | Admin JWT | Update invoice |
| DELETE | /api/admin/invoices/:id | Admin JWT | Delete invoice |
| GET | /api/admin/appointments | Admin JWT | List all appointments |
| POST | /api/admin/appointments | Admin JWT | Create appointment |
| PUT | /api/admin/appointments/:id | Admin JWT | Update appointment |
| DELETE | /api/admin/appointments/:id | Admin JWT | Delete appointment |
| GET | /api/admin/employees | Admin JWT | List all employees |
| POST | /api/admin/employees | Admin JWT | Create employee (with PIN hashing) |
| PUT | /api/admin/employees/:id | Admin JWT | Update employee / reset PIN |
| DELETE | /api/admin/employees/:id | Admin JWT | Delete employee |
Auth
| Field | Value |
|---|---|
| Algorithm | PBKDF2-SHA256 |
| Salt | SONAN_SP_2026 |
| Iterations | 100,000 |
| JWT algorithm | HS256 |
| JWT fallback secret | SONAN_BUILDRIGHT_JWT_2026 |
| Default admin password | Admin@2026 |
Employee PINs use the same PBKDF2 hash. PINs are 4 digits. After hashing, the plaintext PIN is never stored.
Known Gotchas
| Issue | Detail |
|---|---|
| FUSE filesystem truncates large files | Always write to /tmp first, hash from /tmp. Never write large files directly to the mounted workspace path. |
.git/config corruption on FUSE | After 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 terminal | esbuild uses Windows binaries in node_modules — won't run in Linux sandbox. |
wrangler deploy must run from Windows terminal | The sandbox environment gets 403 from Cloudflare. |
| No Supabase — pure D1 SQL | All queries are flat D1 SQL. No nested join results, no arrays inside arrays. |
| Amounts stored as cents | labor_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 base64 | business_logo in the settings table is a base64 data URL. Large payload on /api/config responses but no external file storage is needed. |