General
Tech stack and architecture
Two deployables in one repository: a GraphQL API with its background worker, and a React web app. PostgreSQL is the single source of truth; everything a screen shows is read from it live.
Source: docs/ARCHITECTURE.md, the package.json files of api/ and web/, docker-compose.yml. Versions as of 2026-10-06.
The system at a glance
The browser talks only to the API. The API owns the database. Redis holds no records. All five parts run in the local stack; what each really does today is listed under the diagram.
- Web app, API, PostgreSQL. The whole product. Every page, read and write goes this way.
- Query cache. Wired in the code, but off in the local stack: its Redis image lacks the JSON module the cache needs, so every read goes to PostgreSQL. The API says so in its log at boot.
- Worker. One job has a caller: the password-reset email. No email provider is connected, so the worker only writes the message to its log. The SMS queue has had no caller since one-time codes were removed.
- No CRM feature uses the worker. Health scores, stall flags and dashboards are computed when a page asks for them.
- PostGIS. The geographic extension is switched on in the database, but no table has a geographic column: a Site's place is plain text (address, postcode, region).
Technologies
| Backend | For |
|---|---|
| NestJS 11 | The application framework |
| GraphQL, Apollo Server 5 | The API; the schema is generated from the code |
| Prisma 7 | Database access and migrations |
| PostgreSQL 16 | All domain data |
| PostGIS 3.4 | Geographic extension of PostgreSQL; switched on, not used by any table yet |
| Redis 7 | The job queue's storage; a query cache that is off in the local stack |
| BullMQ 6 | Background jobs in the worker; today only the password-reset email |
| JWT, bcrypt | Sign-in: a 15-minute access token, a 7-day refresh token, hashed passwords |
| Sentry | Error tracking; off on a local stack |
| Frontend | For |
|---|---|
| React 19 | The interface |
| Vite 8 | Dev server and build |
| TypeScript 7 | Types end to end |
| react-router 8 | Pages and URLs; filters and tabs live in the URL |
| Tailwind CSS 4 | Styling; the design tokens are its theme |
| Apollo Client 4 | GraphQL calls, cache, token refresh |
| GraphQL Codegen 7 | Typed queries from the API's schema |
| Tooling | For |
|---|---|
| Node 24.16 | The runtime, pinned in the Dockerfiles |
| pnpm 12 | Dependencies; api/ and web/ are separate workspaces |
| Docker Compose | The local stack of five services |
| Jest 30, PGlite | API tests on a real in-memory Postgres, no mocks |
| Oxlint, Prettier | Linting and formatting |
The path of a request
Every read and write crosses the same six steps.
- Three layers of permission. The resolver checks that the user is signed in and holds an allowed role; the assert service checks the rules of the record itself, such as which function owns the current stage.
- Only what was asked for. The service turns the fields of the GraphQL query into the database selection, so a list page does not load what it does not show.
- Computed on read. Health scores, stall flags, countdowns and totals are worked out when a page asks for them. None is stored, so none can go stale.
One contract, generated types
The API's code is the source of the contract. The files with a dashed border are generated and never edited by hand.
Where things live
| Path | Holds |
|---|---|
api/src/ | The backend, one folder per domain area: site, company, contact, activity, next-action, site-buyer, site-contractor, morning-meeting, dashboard, auth, staff-user and the platform modules |
api/prisma/ | The data model and its migrations |
api/test/ | End-to-end tests and their test database helper |
web/packages/crm/ | The CRM web app: pages, the app shell, the session |
web/shared/ | Shared components, the design tokens, the GraphQL documents and their generated types |
docs/ | Architecture, glossary, runbook, decision records, one spec and plan per feature |
initialDemo/ | This site |
The local stack
One command, docker compose up -d --build, starts five services. Migrations and the demo data are applied before the API accepts requests.
| Service | Runs | Default port |
|---|---|---|
web-crm | The web app's dev server | 5173 |
api | The API, at /graphql | 3001 |
worker | Background jobs | — |
postgres | PostgreSQL 16 with PostGIS | 5432 |
redis | Redis 7 | 6379 |
Every host port can be moved with ./set-ports.sh <base>, so two copies of the stack can run side by side. Setup steps and known problems: docs/RUNBOOK.md.
Decisions that shape the code
Each has a decision record in docs/decisions/.
| Decision | What it means | Record |
|---|---|---|
| A Lead and an Opportunity are one row | A Lead becomes an Opportunity by a change of stage on the same Site. Nothing is copied. | ADR-0019 |
| The stage rules are a table in code | Order, owning function and allowed moves are one constant, served to the web app. | ADR-0017 |
| Derived values are computed on read | Scores, flags and totals have no columns and no background jobs. | ADR-0018, ADR-0024 |
| Roles are checked by the API | The web app locks what a role cannot change, but the API's checks are the rule. | ADR-0016, ADR-0028 |
| GraphQL first, schema from code | One API surface; the web app's types are generated from it. | ADR-0002, ADR-0008 |
| Staff users are created by an admin | There is no sign-up; every account that can sign in holds one known role. | ADR-0029 |