# 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.