109 lines
7.0 KiB
Markdown
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.
|