Files
MOPC-Portal/docs/superpowers/specs/2026-06-04-multi-hotel-rooming-design.md
Matt 42e6b5f8c0 docs(logistics): multi-hotel + room-assignment design spec
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:08:51 +02:00

109 lines
7.0 KiB
Markdown

# Multiple Hotels + Room Assignments — Design Spec
**Date:** 2026-06-04
**Status:** Approved (design), pending implementation
**Context:** The grand-finale logistics feature currently supports exactly **one hotel per edition** (`Hotel.programId @unique`), with no way to say who stays where. Admins need **multiple hotels** and the ability to assign each confirmed attendee to a hotel — usually a whole team together, but with per-member flexibility — including **room number and check-in/out dates**.
> Separately resolved (not part of this spec): the finalist attendee cap is configurable (Admin → Settings → Edition) and was set to 4 in production; because every confirmation path reads `Program.defaultAttendeeCap` live, this applied retroactively to already-sent confirmation links.
## Goals
- Many hotels per edition (CRUD).
- Assign each **confirmed attendee** to a hotel, with **per-member granularity** and a **"assign whole team"** shortcut.
- Track **room number + check-in/check-out** per attendee.
- Surface each attendee's assignment in their team-facing "My Logistics" view and the travel-confirmed email.
## Non-goals (YAGNI)
- Room-sharing modeling (two attendees can simply share a `roomNumber` string — no explicit room entity).
- Hotel booking/availability/pricing.
- External (non-portal) lunch guests are unrelated and untouched.
## Data model
Mirror the existing `FlightDetail` pattern (a 1:1 detail record per `AttendingMember`).
**`Hotel`** — relax the uniqueness so an edition can have many:
```prisma
model Hotel {
id String @id @default(cuid())
programId String // was @unique — now many hotels per edition
name String
address String? @db.Text
link String?
notes String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
stays HotelStay[]
@@index([programId])
}
```
**`HotelStay`** (new, 1:1 with `AttendingMember`):
```prisma
model HotelStay {
id String @id @default(cuid())
attendingMemberId String @unique
hotelId String
roomNumber String?
checkInAt DateTime?
checkOutAt DateTime?
notes String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)
hotel Hotel @relation(fields: [hotelId], references: [id], onDelete: Restrict)
@@index([hotelId])
}
```
- `AttendingMember` gains `hotelStay HotelStay?` (back-relation).
- `onDelete: Restrict` on `hotel` means a hotel with occupants can't be deleted — the router pre-checks and returns a friendly "reassign N occupants first" error.
- Assigning = upsert a `HotelStay`; unassigning = delete it.
## Server (logistics router, `src/server/routers/logistics.ts`)
Replace the 1:1 hotel procedures with a list-based set; add rooming/assignment procedures. All `adminProcedure`, all audited (mirror existing `HOTEL_UPSERT` etc.).
| Procedure | Input | Behavior |
|---|---|---|
| `listHotels` | `{ programId }` | All hotels for the edition + `_count` of stays (occupancy). |
| `createHotel` | `{ programId, name, address?, link?, notes? }` | Create. |
| `updateHotel` | `{ id, name, address?, link?, notes? }` | Update. |
| `deleteHotel` | `{ id }` | Pre-check stays: if >0 → `BAD_REQUEST` "Reassign N occupants first." Else delete. |
| `listRooming` | `{ programId }` | One row per **CONFIRMED** attendee: team (project title), member (user), and their `hotelStay` (hotelId, roomNumber, checkInAt, checkOutAt) or null. Sorted by team then member. |
| `assignStay` | `{ attendingMemberId, hotelId, roomNumber?, checkInAt?, checkOutAt?, notes? }` | Upsert the attendee's `HotelStay`. |
| `assignTeamToHotel` | `{ confirmationId, hotelId, checkInAt?, checkOutAt? }` | For every `AttendingMember` of the confirmation, upsert `HotelStay` with `hotelId` (and optional shared dates); preserve existing `roomNumber`. The "assign whole team" shortcut. |
| `unassignStay` | `{ attendingMemberId }` | Delete the `HotelStay` (no-op safe). |
**Applicant** (`applicant.getMyLogistics`): replace the program-hotel lookup with the caller's `AttendingMember.hotelStay` → return `hotel: { name, address, link, notes } | null` plus `room: { roomNumber, checkInAt, checkOutAt } | null`.
**Email** (`logistics.setFlightStatus` → CONFIRMED, in the `TRAVEL_CONFIRMED` notification metadata): include the attendee's **assigned** hotel + room (from their `HotelStay`) instead of the edition's single hotel. The `getTravelConfirmedTemplate` already accepts a `hotel` object — extend its metadata to carry room/dates.
## Admin UI (`src/components/admin/logistics/hotels-tab.tsx`, reworked)
Two sections:
1. **Hotels** — a list of the edition's hotels; add/edit/delete each (dialog), with an occupancy badge per hotel. Delete shows the "reassign first" error inline.
2. **Rooming** — a table driven by `listRooming`, grouped by team: columns `Member | Hotel (Select) | Room # | Check-in | Check-out`. Each team header has an **"Assign whole team to…"** Select (calls `assignTeamToHotel`). Per-row edits call `assignStay` (debounced on blur for room/dates; immediate on hotel change); clearing the hotel calls `unassignStay`. A **Download CSV** button (mirror the travel/visa export). Empty state when no confirmed attendees yet. shadcn components, visible affordances only (no keyboard shortcuts).
## Team-facing (`src/components/applicant/my-logistics-card.tsx`)
The Hotel section shows the attendee's **assigned** hotel (name/address/link) + **Room** (number) + **check-in/check-out** (Monaco-time labels), or "Hotel details coming soon" when unassigned.
## Migration
- Drop `Hotel_programId_key` unique constraint; add `Hotel_programId_idx`.
- Create `HotelStay` table + FKs (`attendingMemberId` unique → AttendingMember CASCADE; `hotelId` → Hotel RESTRICT) + `HotelStay_hotelId_idx`.
- No data backfill: no `HotelStay` rows exist yet; any existing single `Hotel` row simply becomes the first of many.
- Additive/safe for prod; applied via `prisma migrate deploy` on container start.
## Testing
- Hotel CRUD: create multiple hotels for one program; `deleteHotel` rejected when occupied, succeeds when empty.
- `assignStay` upsert (create then update room/dates); `assignTeamToHotel` assigns all of a team's attendees; `unassignStay` removes.
- `listRooming` returns confirmed attendees with their stay (and null for unassigned).
- `getMyLogistics` returns the assigned hotel + room for the caller; null when unassigned.
- Migration applies cleanly; existing finalist/logistics tests stay green (callers updated from `getHotel`/`upsertHotel`).
## Affected call sites to update (from 1:1 → multi)
- `hotels-tab.tsx` (reworked), `getMyLogistics` (applicant.ts), `setFlightStatus` travel email (logistics.ts), and any other `getHotel`/`upsertHotel` references — grep to confirm before removing the old procedures.