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

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

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])
}
  • 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.