XYZ Aquarium Cleaning Services
Developer Reference
Architecture, DB schema, API routes, file structure, auth flow

Stack Overview

LayerTechnology
FrontendReact 18 + TypeScript, Vite 5, deployed to Cloudflare Pages
Backend APICloudflare Worker (TypeScript), deployed as aquarium-api
DatabaseCloudflare D1 (SQLite) — aquarium_db
File StorageCloudflare D1 BLOB for logo (base64 stored in settings row)
PDF GenerationBrowser window.print() with print-specific CSS; no server-side PDF
AuthAdmin: password hash stored in D1, JWT-like token in localStorage; Employee: phone + PIN stored in D1

File Structure

aquarium-portal/
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ main.tsx                  # Vite entry
โ”‚   โ”œโ”€โ”€ App.tsx                   # Router: public site vs admin vs team login
โ”‚   โ”œโ”€โ”€ context/
โ”‚   โ”‚   โ””โ”€โ”€ AppContext.tsx        # Global state, loadAll(), setView()
โ”‚   โ”œโ”€โ”€ components/
โ”‚   โ”‚   โ”œโ”€โ”€ PublicSite.tsx        # Landing page (public)
โ”‚   โ”‚   โ”œโ”€โ”€ AdminLogin.tsx        # Password form
โ”‚   โ”‚   โ”œโ”€โ”€ AdminLayout.tsx       # Sidebar + outlet
โ”‚   โ”‚   โ”œโ”€โ”€ TeamLogin.tsx         # Employee phone+PIN login
โ”‚   โ”‚   โ””โ”€โ”€ admin/
โ”‚   โ”‚       โ”œโ”€โ”€ DashboardView.tsx
โ”‚   โ”‚       โ”œโ”€โ”€ JobsView.tsx
โ”‚   โ”‚       โ”œโ”€โ”€ CalendarView.tsx
โ”‚   โ”‚       โ”œโ”€โ”€ ClientsView.tsx
โ”‚   โ”‚       โ”œโ”€โ”€ EmployeesView.tsx
โ”‚   โ”‚       โ”œโ”€โ”€ EstimatesView.tsx
โ”‚   โ”‚       โ”œโ”€โ”€ InvoicesView.tsx
โ”‚   โ”‚       โ”œโ”€โ”€ ServiceRequestsView.tsx
โ”‚   โ”‚       โ”œโ”€โ”€ ReportsView.tsx
โ”‚   โ”‚       โ””โ”€โ”€ SettingsView.tsx
โ”‚   โ””โ”€โ”€ types.ts                  # TypeScript interfaces
โ”œโ”€โ”€ worker/
โ”‚   โ””โ”€โ”€ index.ts                  # Cloudflare Worker — all API routes
โ”œโ”€โ”€ public/
โ”‚   โ””โ”€โ”€ user-guide.html           # In-portal help guide (standalone HTML)
โ”œโ”€โ”€ migrations/
โ”‚   โ””โ”€โ”€ 001_init.sql              # D1 schema init migration
โ”œโ”€โ”€ wrangler.toml
โ”œโ”€โ”€ vite.config.ts
โ””โ”€โ”€ package.json

Auth Flow

Admin auth: The login form POSTs the password to /api/auth/login. The Worker hashes the input with SHA-256 and compares against the stored hash in the settings table. On match, it returns a signed token (a timestamped hash). The frontend stores this token in localStorage and sends it as Authorization: Bearer <token> on every subsequent request. The Worker validates the token on all /api/admin/* routes.

Employee auth: The Team Login form POSTs phone + PIN to /api/auth/employee-login. The Worker looks up the employee by phone, hashes the PIN, and compares. On match it returns an employee token scoped to that employee ID. The employee token only grants access to /api/employee/* routes.

Tokens are stored in localStorage — not httpOnly cookies. This is by design for a Cloudflare Pages + Worker setup where cookies require additional CORS coordination.

AppContext & State

All global state lives in src/context/AppContext.tsx. The context exposes:

  • state — object containing jobs, clients, employees, estimates, invoices, requests, settings, adminView, selectedId
  • loadAll() — fetches all collections from the API in parallel
  • setView(view, id?) — dispatches SET_VIEW, setting adminView and selectedId

Pre-selection pattern (used in Jobs, Estimates, Invoices): when navigating from a client card, setView('jobs', jobId) sets selectedId. The target view uses a lazy initializer:

const preJob = state.selectedId
  ? (state.jobs.find(j => j.id === state.selectedId) ?? null)
  : null;
const [selected, setSelected] = useState<Job | null>(preJob);

For JobsView specifically, selectedId can be either a job ID (navigate to job) or an employee ID (filter by employee). The view distinguishes these by checking if the ID matches a job first.

D1 Database Schema

-- Settings (single row, id=1)
CREATE TABLE settings (
  id INTEGER PRIMARY KEY,
  business_name TEXT,
  business_phone TEXT,
  business_email TEXT,
  business_address TEXT,
  logo_base64 TEXT,
  service_types TEXT,          -- JSON array
  invoice_prefix TEXT,
  invoice_next_number INTEGER,
  blocked_days TEXT,           -- JSON array of day numbers
  admin_password_hash TEXT
);

-- Clients
CREATE TABLE clients (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  email TEXT,
  phone TEXT,
  address TEXT,
  notes TEXT,
  created_at TEXT DEFAULT (datetime('now'))
);

-- Employees
CREATE TABLE employees (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  phone TEXT NOT NULL UNIQUE,
  pin_hash TEXT NOT NULL,
  created_at TEXT DEFAULT (datetime('now'))
);

-- Jobs
CREATE TABLE jobs (
  id TEXT PRIMARY KEY,
  client_id TEXT REFERENCES clients(id),
  employee_id TEXT REFERENCES employees(id),
  service_type TEXT,
  scheduled_date TEXT,
  scheduled_time TEXT,
  status TEXT DEFAULT 'scheduled',  -- scheduled | in_progress | completed | cancelled
  price REAL,
  notes TEXT,
  is_recurring INTEGER DEFAULT 0,
  recurrence_type TEXT,             -- weekly | monthly
  created_at TEXT DEFAULT (datetime('now'))
);

-- Estimates
CREATE TABLE estimates (
  id TEXT PRIMARY KEY,
  client_id TEXT REFERENCES clients(id),
  status TEXT DEFAULT 'draft',      -- draft | sent | approved | declined
  line_items TEXT,                  -- JSON array [{desc, amount}]
  subtotal REAL,
  notes TEXT,
  expiry_date TEXT,
  created_at TEXT DEFAULT (datetime('now'))
);

-- Invoices
CREATE TABLE invoices (
  id TEXT PRIMARY KEY,
  client_id TEXT REFERENCES clients(id),
  invoice_number TEXT,
  status TEXT DEFAULT 'unpaid',     -- unpaid | paid
  line_items TEXT,                  -- JSON array [{desc, amount}]
  subtotal REAL,
  paid_at TEXT,
  notes TEXT,
  created_at TEXT DEFAULT (datetime('now'))
);

-- Service Requests
CREATE TABLE service_requests (
  id TEXT PRIMARY KEY,
  name TEXT,
  email TEXT,
  phone TEXT,
  service_type TEXT,
  message TEXT,
  status TEXT DEFAULT 'new',        -- new | converted
  created_at TEXT DEFAULT (datetime('now'))
);

API Routes

All routes are handled in worker/index.ts. The Worker is deployed at xyzaquarium-api.sonandigital.com.

Method + PathPurpose
POST /api/auth/loginAdmin login — returns token
POST /api/auth/employee-loginEmployee login — returns employee token
GET /api/admin/settingsLoad settings (admin)
PUT /api/admin/settingsUpdate settings
GET /api/admin/clientsList all clients
POST /api/admin/clientsCreate client
PUT /api/admin/clients/:idUpdate client
DELETE /api/admin/clients/:idDelete client
GET /api/admin/jobsList all jobs
POST /api/admin/jobsCreate job
PUT /api/admin/jobs/:idUpdate job
DELETE /api/admin/jobs/:idDelete job
GET /api/admin/employeesList employees
POST /api/admin/employeesCreate employee
PUT /api/admin/employees/:idUpdate employee (incl. PIN reset)
DELETE /api/admin/employees/:idDelete employee
GET /api/admin/estimatesList estimates
POST /api/admin/estimatesCreate estimate
PUT /api/admin/estimates/:idUpdate estimate
DELETE /api/admin/estimates/:idDelete estimate
GET /api/admin/invoicesList invoices
POST /api/admin/invoicesCreate invoice
PUT /api/admin/invoices/:idUpdate invoice (incl. mark paid)
DELETE /api/admin/invoices/:idDelete invoice
GET /api/admin/requestsList service requests
PUT /api/admin/requests/:idUpdate request (mark converted)
POST /api/public/contactSubmit contact/quote form (public, no auth)
GET /api/public/settingsBusiness name, logo, services for public site (no auth)
GET /api/employee/jobsEmployee's assigned jobs (employee token)
PUT /api/employee/jobs/:id/statusUpdate job status (employee token)
PUT /api/employee/pinChange own PIN (employee token)

Key Patterns

PDF generation: Estimates and invoices render into a hidden <div> with class print-only. The print CSS hides everything else. window.print() triggers the browser's PDF dialog. No server-side rendering required.

Logo storage: Logos are stored as base64 strings in the settings.logo_base64 column. On the frontend, they render as <img src={settings.logo_base64}> directly. Uploading fires a FileReader, converts to base64, and PUTs to /api/admin/settings.

Recurring jobs: When a job is created with is_recurring = 1, the Worker generates N future instances (weekly: 12 weeks, monthly: 6 months) as separate job rows at creation time. There is no background scheduler — all recurrences are materialized immediately.

CORS: The Worker sets Access-Control-Allow-Origin: https://xyzaquarium.sonandigital.com on all responses. In development, it allows http://localhost:5173. The ENVIRONMENT var controls which origin is allowed.

Theme & Styling

All colors are CSS custom properties defined in src/main.tsx (injected into :root). No hardcoded hex values in components.

VariableValueUsage
--primary#0B7EA3Buttons, active states, links
--sidebar-bg#071F2DAdmin sidebar, public header
--primary-light#E0F2FEHighlight backgrounds
--surface#FFFFFFCard / panel backgrounds
--background#F3F9FBPage background
--text#0F172ABody text
--muted#64748BSecondary text
--border#E2E8F0Card borders, dividers
--success#047857Paid status, completed
--warning#B45309Pending, in-progress
--danger#DC2626Errors, cancelled, declined