Architecture¶
This document explains how QBTime Manager is put together — the system design and how the pieces fit. For what was planned and what got built, see Project Plan & Status.
High-level shape¶
Browser (React SPA, served by Cloudflare Pages)
| fetch() JSON to /api/v1/* (HttpOnly session cookie)
v
Cloudflare Pages Functions / Workers (functions/)
| | | |
v v v v
D1 KV QBT API MS Graph sendMail
(SQL) (cache, (rest.tsheets) (M365 Exchange)
sessions)
^
|
Cron Worker (separate) --> per-company report runner (added Phase 5)
There is no standalone server process. The front end is static files; all server-side logic runs as Cloudflare Workers invoked per request (or per cron tick).
Why this split¶
Cloudflare Pages can only serve static assets. Anything that needs a secret, talks to QBT, stores a token, or sends email must run server-side — that is what the Functions/ Workers layer is for. Pages Functions cannot run cron, so a separate small cron Worker calls the app's internal report endpoint on a schedule (see docs/deploy-app.md).
Workspaces¶
The repo is an npm-workspaces monorepo with three packages:
shared/— framework-agnostic TypeScript types and constants imported by both the client and the functions. This is the single source of truth for role/tier enums, the report-type enum, the API response envelope, and verified QBT limits. Keeping these here prevents the client and server from drifting apart.functions/— the API and business logic. Routes (thin) call services (logic); theEnvtype describes the Cloudflare bindings and secrets available at runtime. A global_middleware.tsapplies transport-security headers to every response.client/— the React SPA. A small typedapi-clientunwraps the{ data, error, meta }envelope so components handle one error shape. Tailwind tokens (defined as CSS variables) drive light/dark theming from one class toggle.
Multi-tenancy model¶
The hierarchy is customers -> companies -> data. Isolation is enforced at both
customer_id and company_id on every query (server-side, never hidden-in-UI only). QBT
user/schedule/jobcode IDs are only unique within a company, so they are always stored and
looked up together with company_id. Each company has its own QBT service token; the Worker
resolves the correct token by company_id before calling QBT.
A third platform/DevOps tier sits above customers (provider-only, invisible to customers) and is gated by a platform role check that is separate from the customer role — there is no escalation path from a customer account to the platform plane.
Data layer (D1)¶
The schema lives in migrations/0001_init.sql. Notable choices:
- Timestamps are UTC ISO-8601 text (SQLite has no native datetime type).
- Secret/token columns are suffixed
_encand store ciphertext only; the app envelope- encrypts before insert. No plaintext secret is ever stored. audit_logis append-only and hash-chained (prev_hash/row_hash). DB triggers rejectUPDATEandDELETEso tampering is both prevented and detectable.report_runshas a partial unique index on(report_config_id, report_date)to make scheduled sends idempotent — a retried or double-fired cron cannot email the same report twice.- Foreign keys and frequently-filtered columns are indexed.
Request/response contract¶
Every endpoint returns { data, error, meta }. Success puts the payload in data with
error: null; failure puts a user-safe { code, message } in error with data: null.
Stack traces are never returned. Lists use { data, total, page, limit }.
What is NOT here yet¶
Auth, MFA, CSRF, rate-limiting, the QBT client, the attendance engine, email sending, the
report runner, and all feature screens are added in later phases. The routes/, services/,
middleware/, and config/ folders are present (with READMEs) so the structure is fixed
before feature code lands, per the project's "scaffold before code" rule.