A full-stack web application for booking X-ray CT scanners, managing projects, customers, and generating reports on machine utilisation and project performance.
| If you want to… | Read |
|---|---|
| Get the stack running locally for development | This file, Quick Start (Development) |
| Deploy to a server fronted by Traefik for the first time | deploy.md — full walk-through (server prerequisites, Let's Encrypt, day-to-day ops, troubleshooting) |
| Import historic calendar / spreadsheet data into a fresh DB | This file, Production Deployment (Traefik) §3 |
| Promote data from a test deployment into a fresh production one | goLive.md — pg_dump test → restore on prod |
| Look up the architecture / API / pages / design decisions | The reference sections lower in this file |
ct-booking/
├── ct-booking-api/ # NestJS 11 + Prisma 6 backend (TypeScript)
│ ├── prisma/ # Schema + seed + migrations
│ ├── src/
│ │ ├── common/ # Guards, decorators, interceptors
│ │ └── modules/ # 11 feature modules
│ └── Dockerfile
├── ct-booking-fe/ # Angular 21 + PrimeNG 19 + Tailwind 4.2 frontend
│ ├── src/app/
│ │ ├── core/ # Services, models, guards, interceptors
│ │ └── features/ # 21 standalone page components
│ ├── nginx.conf
│ └── Dockerfile
├── database/ # Raw SQL schema (reference)
├── docker-compose.yml # Production: Postgres + API + Frontend
└── docker-compose.local.yml # Dev: Postgres only
| Layer | Technology |
|---|---|
| Frontend | Angular 21, Tailwind CSS 4.2, PrimeNG 19, Chart.js |
| Backend | NestJS 11, Prisma 6, passport-jwt, Swagger |
| Database | PostgreSQL 16 |
| Auth | JWT Bearer tokens (8h expiry) |
| Containers | Docker, Docker Compose, nginx reverse proxy |
# Fresh install: wipe any existing dev volume first so migrations and seeds
# run against an empty DB. Omit `-v` if you want to keep existing data.
docker compose -f docker-compose.local.yml down -v 2>/dev/null
docker compose -f docker-compose.local.yml up -dcd ct-booking-api
cp .env.example .env
yarn install
yarn prisma:generate
yarn prisma:migrate # applies all migrations, including Unknown statusThe recommended path is one command that does baseline + every historic CSV in a directory:
# from ct-booking-api/
yarn seed:all /path/to/data-dirseed:all runs prisma:seed (baseline users, systems, customers, demo projects)
then seed:historic for every *.CSV it finds in the directory, sorted by filename.
It's idempotent — safe to re-run any time.
If you want to run things piecemeal, the individual scripts still work:
yarn prisma:seed # baseline only
yarn seed:historic /path/to/mt.CSV # one scanner at a timeOutlook CSV exports per scanner (mt.CSV, tu.CSV, w3.CSV, z6.CSV, n4.CSV)
are imported by seed.historic.ts. The system code is taken from the filename
(mt.CSV → MT). Subjects matching an existing project are reused; everything
else is created with status Unknown and anchored to its earliest booking date.
Generated outputs (rejections + a high-level import_summary.csv) land in a
sibling summary/ folder of the data directory — e.g. data/ → summary/.
This keeps the data folder free of generated files so a re-run only iterates
the fresh inputs you've placed there.
summary/rejections_<SYSTEM>.csv— one file per scanner, full row + reason.summary/import_summary.csv— one row per scanner (counts + flattened rejection subjects), upserted on re-run.
Project metadata imports from the NXCT spreadsheets stay manual:
yarn seed:projects "/path/to/NXCT Database P2 Prototype.xlsx" \
"/path/to/NXCT Database Updated LOCAL.xlsx"# API (from ct-booking-api/)
yarn start:dev# Frontend (from ct-booking-fe/)
yarn install
yarn start- API:
http://localhost:3000— Swagger docs:http://localhost:3000/api/docs - UI:
http://localhost:4200(dev server proxies/apito backend automatically)
Default login: admin@ctbooking.local / changeme123
To start over with a fresh database (drops all data, including imports):
# from ct-scan/
docker compose -f docker-compose.local.yml down -v # wipe DB volume
docker compose -f docker-compose.local.yml up -d # bring DB back up
cd ct-booking-api
yarn prisma:migrate # apply schema migrations
# seed:all = baseline seed (prisma/seed.ts) + seed:historic for every *.CSV
# in the directory, in alphabetical order. Rejections + import_summary.csv
# land in a sibling `summary/` folder next to <data-dir>.
yarn seed:all /path/to/data-dir
# optional — project metadata import from the NXCT spreadsheets
yarn seed:projects "/path/to/P2.xlsx" "/path/to/Updated_LOCAL.xlsx"The repo ships two compose files for production:
| File | Purpose |
|---|---|
docker-compose.yml |
Base stack: db, api, frontend |
docker-compose.traefik.yml |
Override that adds the traefik-public external network and routing labels on frontend so Traefik picks it up |
Both are applied together with -f flags. The base file alone is fine for direct bare-metal hosting (frontend on :80); the Traefik override is what you want on a server already running Traefik with TLS termination.
For a full, walk-through deployment guide (server prerequisites, Let's Encrypt, rollback, etc.) see deploy.md. What's below is the short version, focused on the new historic-data import.
Create .env at the repo root. docker-compose.yml reads it directly. Minimum required vars are the first four; the rest are optional and have sensible defaults.
cat > .env <<'EOF'
# Routing + TLS
DOMAIN=ct-booking.example.com
PUBLIC_URL=https://ct-booking.example.com
# Secrets — avoid @ : / ? in DB_PASSWORD (it goes into DATABASE_URL).
# Generate strong values with `openssl rand -base64 48`.
DB_PASSWORD=<strong-random>
JWT_SECRET=<strong-random>
# Optional
# JWT_EXPIRY=8h
# Optional SMTP — if SMTP_HOST is blank, reset links are logged to API stdout.
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=noreply@ct-booking.example.com
# SMTP_PASS=...
# SMTP_FROM=noreply@ct-booking.example.com
EOF
chmod 600 .env| Variable | Required | Used for |
|---|---|---|
DOMAIN |
yes | Traefik host rule on the frontend service |
PUBLIC_URL |
yes | API CORS_ORIGIN and base URL for reset-password links (APP_URL) |
DB_PASSWORD |
yes | Postgres role password — appears in DATABASE_URL (avoid @ : / ?) |
JWT_SECRET |
yes | Session token signing — app refuses to start without it |
JWT_EXPIRY |
no | Token lifetime, default 8h |
SMTP_HOST … SMTP_FROM |
no | Outbound mail for password resets; blank SMTP_HOST falls back to stdout logging |
The traefik-public Docker network must exist (docker network ls | grep traefik-public). It's created by the Traefik install — not by this stack.
For local API development (running the API directly with yarn start:dev, no Docker for the API), ct-booking-api/.env.example is the template — copy it to ct-booking-api/.env and adjust. The variables there are the same set, just with DATABASE_URL written out longhand instead of derived from DB_PASSWORD.
# from ct-scan/ (the -v drops the pgdata volume; skip it to keep existing data)
docker compose -f docker-compose.yml -f docker-compose.traefik.yml down -v
docker compose -f docker-compose.yml -f docker-compose.traefik.yml build
docker compose -f docker-compose.yml -f docker-compose.traefik.yml up -dOn startup the API container runs prisma migrate deploy then dist/prisma/seed.js (no-op if users already exist), so once it's healthy you have the baseline schema, demo users, systems, customers, and example projects.
The seed scripts (seed.all.ts, seed.historic.ts, optionally seed.projects.ts) aren't baked into the production image — they live at the repo root and are run on-demand. The yarn scripts in ct-booking-api/package.json reference them as ../seed.*.ts, so once we drop them in at / inside the container (one level up from /app), the same yarn seed:all flow that works in dev works in prod too.
# from ct-scan/ on the server (or your workstation, if you have docker context set)
# 1. Copy the scripts into / inside the api container
docker compose -f docker-compose.yml -f docker-compose.traefik.yml cp \
seed.all.ts api:/seed.all.ts
docker compose -f docker-compose.yml -f docker-compose.traefik.yml cp \
seed.historic.ts api:/seed.historic.ts
# 2. Copy your scanner CSVs into the container. No chown needed — the seed
# writes its outputs to a sibling /tmp/summary/ folder (created on demand
# as the `nestjs` user), not back into /tmp/data/.
docker compose -f docker-compose.yml -f docker-compose.traefik.yml cp \
/path/to/data api:/tmp/data
# 3. Run the orchestrator (baseline seed is a no-op since the API container
# already ran it on startup; the historic seed runs once per *.CSV)
docker compose -f docker-compose.yml -f docker-compose.traefik.yml exec api \
yarn seed:all /tmp/data
# 4. Pull the generated summary + rejections back to your workstation
docker compose -f docker-compose.yml -f docker-compose.traefik.yml cp \
api:/tmp/summary/. ./summary/The historic importer is idempotent at booking level — re-running won't create duplicates. All outputs (per-scanner rejections_<SYSTEM>.csv plus the rolled-up import_summary.csv) live in /tmp/summary/ inside the container, kept separate from /tmp/data/ so re-runs only iterate the original CSV inputs you copied in.
For the project metadata (xlsx) import, copy seed.projects.ts and drop the spreadsheets into the same /tmp/data/ folder (seed:all only picks up *.CSV so the .xlsx files there won't be processed by the historic importer). rejections_projects.csv lands in the same /tmp/summary/ folder as the historic rejections:
docker compose -f docker-compose.yml -f docker-compose.traefik.yml cp \
seed.projects.ts api:/seed.projects.ts
docker compose -f docker-compose.yml -f docker-compose.traefik.yml cp \
"/path/to/NXCT Database P2 Prototype.xlsx" api:/tmp/data/P2.xlsx
docker compose -f docker-compose.yml -f docker-compose.traefik.yml cp \
"/path/to/NXCT Database Updated LOCAL.xlsx" api:/tmp/data/LOCAL.xlsx
docker compose -f docker-compose.yml -f docker-compose.traefik.yml exec api \
yarn seed:projects /tmp/data/P2.xlsx /tmp/data/LOCAL.xlsxdocker compose -f docker-compose.yml -f docker-compose.traefik.yml ps
curl -fsSL https://${DOMAIN}/api/docs -o /dev/null && echo "API proxy OK"Default seed login (change immediately on a public deployment): admin@ctbooking.local / changeme123.
| Table | Description |
|---|---|
| users | Engineers / operators who manage scanners |
| systems | 5 X-ray CT scanners (Tescan UniTOM, Zeiss Metrotom, Waygate, Zeiss Versa 620, Nikon XT H 450) |
| customers | External contacts — academic or company, with autocomplete for institutions/companies/groups |
| projects | Primary entity: type (FATPOA/Quoted/TRAC/PhD/etc.), status lifecycle, quoted days, cost centres, document links |
| bookings | Time-slot reservations on a system for a project, with overlap detection |
| nxct_cost_centres | FATPOA budget tracking: scan/analysis day targets with date ranges; is_archived flag hides from dropdowns |
| outcomes | Papers, conferences, funding linked to projects (M:M) |
| disciplines | Lookup: Chemistry, Engineering, Physics, Life Sciences, etc. |
| project_status_history | Every status transition with timestamp and user |
| project_notes | Timestamped notes per project, newest first |
| day_quotas | Annual FATPOA/Paid day targets per Nov–Oct year |
All endpoints prefixed with /api/, JWT-authenticated except /api/auth/login.
| Resource | Key Endpoints |
|---|---|
| Auth | POST /auth/login |
| Users | GET, POST /users · GET, PATCH, DELETE /users/:id |
| Systems | GET, POST /systems · PATCH /systems/:id (isActive to archive) |
| Disciplines | GET, POST /disciplines · PATCH, DELETE /disciplines/:id |
| Customers | GET, POST /customers · PATCH, DELETE /:id · autocomplete (institutions, companies, groups) |
| Projects | GET, POST /projects · GET, PATCH /:id · POST /:id/notes · GET /export/csv |
| Bookings | GET, POST /bookings · PATCH, DELETE /:id (overlap detection on create/update) |
| NXCT Cost Centres | GET, POST /nxct-cost-centres · PATCH /:id (isArchived to archive; ?includeArchived=true to list all) |
| Outcomes | GET, POST /outcomes · PATCH /:id · POST /:id/projects/:projectId |
| Reports | recently-closed · open-jobs · potential-work · jobs-to-invoice · summary · system-usage |
| Section | Route | Description |
|---|---|---|
| Calendar | /calendar |
Week and month view of all systems; double-click to book; quick-slot buttons (9–1, 1–5, all day); click a booking to edit or delete it |
| Projects | /projects |
Searchable paginated list with type/status filters and CSV export |
/projects/new |
Create form with customer autocomplete, conditional TRAC/FATPOA fields, NXCT cost centre dropdowns | |
/projects/:id/edit |
Edit form (reuses create form with pre-populated data) | |
/projects/:id |
Detail view: info panel, status update with confirmation dialog, notes, status history timeline, NXCT cost centres | |
| Customers | /customers |
Searchable list |
/customers/new |
Create form with academic/company toggle, institution/company/group autocomplete | |
/customers/:id/edit |
Edit form with delete button (inline confirmation) | |
| Search | /search |
Tabbed search: projects (with "empty field" filter + inline edit dialog), customers, bookings |
| Outcomes | /outcomes |
Paper/conference/funding tracking with create dialog |
| Reviews | /reviews/recently-closed |
Projects completed/invoiced in last 7 days |
/reviews/open-jobs |
Active FATPOA/Quoted/TRAC with inline note-add | |
/reviews/potential-work |
Discussion/Quoted/NDA pipeline with quick new-project link | |
/reviews/jobs-to-invoice |
Completed Quoted/TRAC awaiting invoice | |
/reviews/summary |
Multi-year scan/analysis day comparison by type with NXCT cost centre and day quota tables | |
| Reports | /reports/system-usage |
Per-system utilisation with Chart.js bar + doughnut charts and usage-type breakdown |
/reports/nxct-fatpoa |
FATPOA projects by cost centre with progress cards (target/used/remaining) | |
/reports/wmg-projects |
WMG (TRAC) projects with scan/analysis totals | |
/reports/uow-projects |
University of Warwick projects filtered by customer institution | |
| Admin | /admin |
Tabbed management: Users (create/edit/delete), Systems (edit/archive), NXCT Cost Centres (edit/archive), Disciplines (create/edit/delete) |
| Concern | Implementation |
|---|---|
| Authentication | JWT via passport-jwt; authInterceptor attaches Bearer token; authGuard protects all routes |
| Error handling | Global errorInterceptor: 401 → auto-logout, 403 → toast, network errors → toast |
| Notifications | NotificationService wraps PrimeNG MessageService — success/error/warn/info toasts on all mutations |
| Confirmations | ConfirmService wraps PrimeNG ConfirmationService — promise-based, used on status changes |
| Validation | Backend: NestJS ValidationPipe with class-validator DTOs. Frontend: required-field checks before submit |
| CSV Export | Server-side export via /projects/export/csv with current filters applied |
| Booking conflicts | API checks for overlapping bookings on the same system before insert/update; navigates to conflict week on error |
| Archiving | Systems and NXCT cost centres are archived (not deleted) — archived systems are hidden from the calendar and booking dialogs |
| Unit tests | Jest + jest-preset-angular; tests cover booking CRUD, month/week view logic, customer delete, admin manage (users, systems, cost centres, disciplines) |
- Prisma ORM over raw SQL — type-safe queries, automatic migrations, seed scripts. Raw
schema.sqlincluded for reference. - Status history as a separate table — enables "date status changed to X" queries from the requirements.
- Project notes as first-class entities — multiple timestamped entries per project, not a single text field.
- Angular signals throughout — reactive state without RxJS boilerplate in components.
- Standalone components only — no NgModules in the frontend; every component self-declares its imports.
- Lazy-loaded routes — each page loads on demand via
loadComponent(). - Tailwind 4 + PrimeNG — Tailwind for layout/spacing, PrimeNG for data-heavy widgets (tables, dialogs, autocomplete).