Rehearsal room booking for the Nottingham New Theatre. Members request a room and a window; committee admins assign an internal room, arrange an external venue, or reject with a reason.
Live at: rooms.newtheatre.org.uk · Owner: IT Manager / Archivist · Auth: shared NNT
sign-on via stage-door, this app never writes the
session.
| Layer | Choice |
|---|---|
| Framework | Nuxt 4, Vue 3, Nuxt UI 4 |
| Server runtime | Nitro on cloudflare_module, a Cloudflare Worker, not Node |
| Database | SQLite: a local file in development, Cloudflare D1 in production |
| ORM | Drizzle, via NuxtHub's hub:db layer |
| Auth | nuxt-auth-utils, reading the estate's sealed nnt-session cookie |
| Resend | |
| Content | @nuxt/content v3 for the user guides under content/docs/ |
This app used Prisma until 2026-08-18; it now matches the rest of the estate (proscenium, stage-door, rehearsal). The existing production tables were kept as they were rather than rebuilt, which is worth understanding before you touch a migration: ADR-0001.
This project uses Bun; bun.lock is the only lockfile.
git clone https://github.com/newtheatre/rooms && cd rooms
bun install
cp .env.example .env # fill in per the comments in that file
bun run dev # http://localhost:3000There is no auth service running locally, so sign in with /dev-login (an ordinary member) or
/dev-login?admin=1 (rooms:ADMIN). That route is guarded by import.meta.dev and does not exist
in a production build.
A request moves through one of three paths, all driven from /admin/bookings:
PENDING ──► CONFIRMED an internal room is assigned
──► AWAITING_EXTERNAL ──► CONFIRMED an external venue is being arranged
──► REJECTED with a reason, shown to the requester
CANCELLED is terminal and available to the requester while a booking is PENDING or CONFIRMED.
A space is considered occupied by bookings in CONFIRMED, PENDING or AWAITING_EXTERNAL, a
pending request holds its slot, so two people cannot both be told yes
(server/utils/availability.ts). Admins can override a conflict deliberately.
Recurring requests expand to one booking row per occurrence at creation. There is no series entity: occurrences are approved, moved and cancelled individually.
The estate's sealed nnt-session cookie is written only by auth.newtheatre.org.uk; this app reads
it with getUserSession(). rooms:ADMIN is the only role this app owns; being signed in is
enough to make a booking request.
The session contract is @newtheatre/auth-types, published from stage-door's packages/auth-types
(stage-door ADR-0025). Do not redeclare it here: change the source and bump its version.
Full integration story: stage-door docs/integrating-an-app.md.
bun run dev # local dev server
bun run lint # eslint
bun run typecheck # nuxt typecheck
bun run build # the production Worker bundle
bun run db:generate # generate a migration from schema changes (review the SQL!)
bun run db:migrate # apply migrations to the local databaseCI (.github/workflows/ci.yml) runs lint, typecheck and build on every
push and PR. Cloudflare Workers Builds deploys main; deployment is not CI's job.
Migrations are. .github/workflows/migrate.yml applies pending migrations on any push to main
that touches server/db/migrations/**. Workers Builds only builds and deploys, and nothing applied
migrations until this workflow existed; that gap took the estate down for an hour on 2026-08-19
(stage-door ADR-0021). Every run records a D1 Time Travel restore point before applying and gates on
the _hub_migrations ledger afterwards. It needs a CLOUDFLARE_API_TOKEN repository secret with
D1:Edit, and fails loudly without one.
./.github/scripts/pending-migrations.sh lists what is pending without applying it, and
GET /api/health returns 503 naming the pending files if a deploy lands ahead of its migration.
Recorded here rather than left to be discovered:
- There are no tests, and no test harness. proscenium is in the same position; rehearsal and stage-door are not.
- Web push is not implemented.
/api/notifications/subscriberecords a subscription and the settings page offers the PUSH channel, butsendPushNotificationlogs and returns (server/utils/notifications.ts). Selecting PUSH delivers nothing. - Two tables still filter and page in the browser.
/admin/bookingsand/requestsfilter, sort and page client-side, so they walk every page of the API to get the whole list back (fetchAllPages). The endpoints themselves page in SQL; moving those two tables onto it is what remains, and until then they still transfer the whole table, just in 200-row requests. PUT /api/bookings/bulkre-reads each booking after updating it, so a long batch is two round-trips per row. The writes themselves are one statement each, which the D1 parameter cap requires.- A stale session is only refreshed on navigation.
app/middleware/admin.tsbounces through the auth service's refresh endpoint when the route middleware runs, but nothing intercepts a401 { stale: true }from a$fetch. Those surface as an error toast, and the person has to navigate for the refresh to happen. Wiring an interceptor means deciding what it does to a half-filled form, which is why it has not been done here. - Venue assignment has no availability preview. There is no
/api/venues/availableto match the rooms one, so the admin venue picker does not mark which venues are taken. Assigning a clashing venue is still refused server-side with a 409, so this costs a wasted click rather than a double booking.
Note that content/docs/ is user-facing guidance published to the site. Engineering
documentation lives in docs/.
| Doc | Read it when… |
|---|---|
| docs/data-model.md | you're changing the schema, or need the booking status lifecycle |
| docs/api-reference.md | you're calling or changing an endpoint |
| docs/decisions/ | you're about to ask "why on earth is it done this way?" |
stage-door docs/integrating-an-app.md |
you're touching anything to do with sessions, roles or the GDPR hooks |
A comment carries what the code cannot: a constraint, a trap, a contract that is not obvious from the signature. It does not narrate, and it does not restate the signature. Anything needing a paragraph of justification belongs in a decision record, not a header. See CLAUDE.md §Comments.