7.0 KiB
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.defaultAttendeeCaplive, 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
roomNumberstring — 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:
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):
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])
}
AttendingMembergainshotelStay HotelStay?(back-relation).onDelete: Restrictonhotelmeans 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:
- 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.
- Rooming — a table driven by
listRooming, grouped by team: columnsMember | Hotel (Select) | Room # | Check-in | Check-out. Each team header has an "Assign whole team to…" Select (callsassignTeamToHotel). Per-row edits callassignStay(debounced on blur for room/dates; immediate on hotel change); clearing the hotel callsunassignStay. 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_keyunique constraint; addHotel_programId_idx. - Create
HotelStaytable + FKs (attendingMemberIdunique → AttendingMember CASCADE;hotelId→ Hotel RESTRICT) +HotelStay_hotelId_idx. - No data backfill: no
HotelStayrows exist yet; any existing singleHotelrow simply becomes the first of many. - Additive/safe for prod; applied via
prisma migrate deployon container start.
Testing
- Hotel CRUD: create multiple hotels for one program;
deleteHotelrejected when occupied, succeeds when empty. assignStayupsert (create then update room/dates);assignTeamToHotelassigns all of a team's attendees;unassignStayremoves.listRoomingreturns confirmed attendees with their stay (and null for unassigned).getMyLogisticsreturns 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),setFlightStatustravel email (logistics.ts), and any othergetHotel/upsertHotelreferences — grep to confirm before removing the old procedures.