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