Skip to content

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); the Env type describes the Cloudflare bindings and secrets available at runtime. A global _middleware.ts applies transport-security headers to every response.
  • client/ — the React SPA. A small typed api-client unwraps 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 _enc and store ciphertext only; the app envelope- encrypts before insert. No plaintext secret is ever stored.
  • audit_log is append-only and hash-chained (prev_hash / row_hash). DB triggers reject UPDATE and DELETE so tampering is both prevented and detectable.
  • report_runs has 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.