WorkSmart Docs
Technical documentation

Architecture & Technical Decisions

How WorkSmart is put together — the runtime topology, the data model, the request and data flows, and the engineering decisions (with their trade-offs) that shaped the build.

Stack TypeScript · React · Fastify · Drizzle · Postgres Repo Turborepo + pnpm monorepo Deploy Docker Compose on a single EC2 host

Contents

  1. System overview
  2. Runtime architecture
  3. Data model
  4. API surface
  5. Key flows
  6. Build & deploy
  7. Key decisions & trade-offs

01System overview

A small, sharp monorepo with a deliberately decoupled inference service.

WorkSmart is a Turborepo + pnpm monorepo. Two deployable apps sit alongside three shared packages, and a single source of truth for every contract lives in @worksmart/types so the browser, the server, and the database all agree on shapes.

Apps

  • web (@worksmart/web) — React 18 + TanStack Router/Query + Vite, styled with StyleX. Ships as a static bundle.
  • api (@worksmart/api) — Fastify 5 run straight from TypeScript via tsx. Owns all CRUD, auth, presigned uploads, and AI proxying.

Packages

  • types — Zod schemas: the check-in grammar, AI wire contracts, pagination cursors. Imported by both apps.
  • ui — StyleX component library (Button, Input, Select, Card, Badge) + design tokens.
  • config — shared TypeScript & Biome configuration.

The GenAI inference service (tm-worksmart-ai-service, FastAPI) lives in a separate repository and is reachable only through the api. The browser never holds the service token, and the inference layer can be deployed and evolved on its own cadence.

02Runtime architecture

Everything is a container on one EC2 host; prod mirrors local dev one-to-one.

The web container (nginx) serves the static bundle and reverse-proxies /api/* to the api on the same origin — so there is no browser CORS in play. Document bytes never pass through the api: the browser uploads and downloads them directly to object storage using short-lived presigned URLs.

Browser React SPA EC2 HOST · docker compose web — nginx serves dist/ · proxies /api → api api — Fastify (tsx) :3001 · CRUD · auth · AI proxy postgres :5432 persistent volume · pgdata AWS S3 documents bucket tm-worksmart-ai-service FastAPI · separate repo reads storage by key HTTP :80 SQL (Drizzle) presign AI_SERVICE_URL · bearer direct upload / download via presigned URL
Why same-origin proxy

nginx serving the SPA and proxying /api on one origin means zero CORS configuration and one TLS certificate. The Vite dev server replicates the exact same proxy (stripping the /api prefix), so what you test locally is what runs in production.

03Data model

Six tables. Time, work, and documents, joined by people.

The schema is defined once in Drizzle (apps/api/src/db/schema.ts) and migrations are the committed SQL in apps/api/drizzle/. drizzle-zod keeps the database shape and the Zod validation contracts in lock-step.

users id · pk name · email department · role accessRole clerkUserId projects id · pk name · key status ownerId → users tasks id · pk projectId → projects title · status priority · dueDate assigneeId → users checkins id · pk userId → users taskId → tasks (nullable) hours · tag · date rawText activityDescription documents id · pk type · vendor status storageKey · fileName uploadedBy → users extractedData · jsonb checkin_documents checkinId → checkins documentId → documents pk (checkinId, documentId) 1 — N owns 1 — N logs 1 — N rolls up uploads N — M link

Two design notes worth calling out. First, checkins.taskId is nullable: time can be logged loosely or attributed to a task so it rolls up into project totals — the link is optional, never required. Second, documents.extractedData is a jsonb column that holds the full AI analysis result verbatim, so the inference contract can evolve without a migration.

04API surface

A flat, resource-oriented Fastify API. Identity on every request; ownership enforced server-side.

AreaEndpoints
IdentityGET /me · GET /users · GET /health
Check-insGET /checkins (keyset) · POST /checkins · PATCH /checkins/:id · DELETE /checkins/:id · GET /checkins/stats
Projects & tasksGET·POST·PATCH·DELETE /projects · GET /projects/:id/summary · POST /projects/:id/status-narrative · GET·POST·PATCH·DELETE /tasks
DocumentsPOST /documents/presign · POST·GET /documents · GET /documents/:id/download · PATCH·DELETE /documents/:id · POST /documents/:id/analyze · GET·POST·DELETE link endpoints
GenAIPOST /categorize · GET /anomalies · POST /search

05Key flows

Document upload — bytes bypass the API

Uploads are a two-step handshake. The api mints a presigned PUT URL; the browser streams the file straight to object storage; then the browser confirms, and the api persists only the metadata row. Downloads work the same way in reverse with a presigned GET.

Browser api S3 / MinIO postgres 1 · POST /documents/presign presigned PUT URL 2 · PUT file bytes (direct) 3 · POST /documents (confirm) INSERT metadata row

Pagination — keyset, not offset

The check-in feed uses cursor (keyset) pagination over a composite (created_at, id) index. It stays stable while rows are inserted concurrently and never degrades the way OFFSET does deep into a list — which matters for a feed that grows every working day. The web renders it with a virtualized list, so a 1,200-row history scrolls at 60fps.

06Build & deploy

Push to main → quality gate → images to ECR → SSM deploy to the host.

push → main CI gatelint · test · build build imagesOIDC · no static keys push → ECR SSM deploypull · up -d · migrate

Migrations run as a one-shot api-migrate service that the api depends_on with service_completed_successfully — schema is always applied before the server accepts traffic. Rollback is re-deploying a previous image tag.

07Key decisions & trade-offs

Each choice optimizes for a small team shipping a vertical slice at a time — and says no to the alternatives on purpose.

Vertical-slice first

Phase 0 proved the whole stack end-to-end before any feature was layered on.

Trade-off: less up-front breadth, but every later phase landed on a known-good spine.

Drizzle + drizzle-zod

The DB schema and the Zod contracts are single-sourced; validation and types never drift.

Trade-off: a lighter ORM than the heavyweights, chosen for fast cold starts and small images.

Decoupled AI by storage key

The inference service reads documents from object storage by key — bytes aren’t shipped in requests, and it deploys independently.

Trade-off: one more moving part, bought with a clean, evolvable inference boundary.

Presigned uploads

File bytes never touch the api or a server session, so the upload path is stateless and scales trivially.

Trade-off: storage CORS to configure, in exchange for no large-body handling in the app.

Run TypeScript via tsx

The api runs from source — no compile step, no cross-package build resolution to debug.

Trade-off: ship dev dependencies in the image; gained a dead-simple, fast container.

Containers on one EC2 host

Production is the same docker compose shape as local dev — one reproducible host, no serverless packaging.

Trade-off: manual horizontal scale later, for radical simplicity now.

Keyset pagination

Index-backed cursors stay stable under concurrent inserts and don’t rot at depth.

Trade-off: no random page jumps — the right call for an append-heavy feed.

Auth with a dev bypass

Clerk in production; an offline X-User-Id mode for local dev and grading. Access roles are enforced either way.

Trade-off: a clearly-fenced bypass path, never enabled in production builds.

The throughline

Every decision favours one obvious way to do things over configurable flexibility — a single source of truth for contracts, a single host shape across environments, a single “AI suggests / you confirm” interaction model. That is what keeps a small codebase fast to change.