Files
MOPC-Portal/docs/superpowers/plans/2026-06-04-multi-hotel-rooming.md
Matt 200b5e0cb9 docs(logistics): multi-hotel + rooming implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:09:49 +02:00

7.8 KiB

Multiple Hotels + Room Assignments — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use - [ ].

Goal: Replace the one-hotel-per-edition model with many hotels + per-attendee room assignments (hotel, room number, check-in/out), assignable per-member or per-team, surfaced to teams and in the travel email.

Architecture: New HotelStay 1:1 detail record per AttendingMember (mirrors FlightDetail); Hotel.programId becomes non-unique. Logistics router gains list/CRUD + rooming/assignment procedures; the Hotels tab is reworked into Hotels + Rooming sections. Spec: docs/superpowers/specs/2026-06-04-multi-hotel-rooming-design.md.

Tech Stack: Next.js 15, tRPC 11, Prisma 6, shadcn/ui, Vitest 4. One schema migration (additive + drop one unique constraint).

Verified facts: Hotel (schema.prisma) is programId @unique; FlightDetail is the 1:1-detail pattern to mirror. 1:1 hotel callers to update: logistics.ts:13 (getHotel), :17/33 (upsertHotel), :231 (travel email program-hotel lookup), applicant.ts:2899 (getMyLogistics), hotels-tab.tsx:20/37/40.


Task 1: Schema — many hotels + HotelStay

Files: prisma/schema.prisma, migration.

  • Step 1: Edit Hotel: remove @unique from programId; add stays HotelStay[] and @@index([programId]).
  • Step 2: Add HotelStay model exactly as in the spec (1:1 attendingMemberId @unique, required hotelId, roomNumber?, checkInAt?, checkOutAt?, notes?, timestamps; attendingMember relation onDelete: Cascade; hotel relation onDelete: Restrict; @@index([hotelId])). Add hotelStay HotelStay? to AttendingMember.
  • Step 3: npx prisma migrate dev --name multi_hotel_and_hotel_stay → migration created + applied + client regenerated. Confirm the generated SQL drops Hotel_programId_key, adds the index, and creates HotelStay with both FKs.
  • Step 4: npm run typecheck (existing getHotel/upsertHotel still compile until Task 2). Commit: git add prisma/ && git commit -m "feat(hotel): many hotels per edition + HotelStay (room assignment)"

Task 2: Logistics router — hotels CRUD + rooming + assignment + travel email

Files: src/server/routers/logistics.ts; test tests/unit/logistics-hotels.test.ts.

Replace getHotel/upsertHotel with the full set (read the spec table for exact inputs):

  • listHotels({ programId }) — hotels + _count: { stays }.
  • createHotel / updateHotel / deleteHotel (deleteHotel: pre-count stays; if >0 throw BAD_REQUEST "Reassign N occupant(s) first"). All audited.
  • listRooming({ programId }) — one row per CONFIRMED AttendingMember in the program: { attendingMemberId, projectId, projectTitle, user{id,name,email}, stay: { hotelId, roomNumber, checkInAt, checkOutAt } | null }, sorted by project title then user name.
  • assignStay({ attendingMemberId, hotelId, roomNumber?, checkInAt?, checkOutAt?, notes? }) — upsert HotelStay (validate the hotel belongs to the same program as the attendee). Audit HOTEL_STAY_ASSIGN.
  • assignTeamToHotel({ confirmationId, hotelId, checkInAt?, checkOutAt? }) — for each AttendingMember of the confirmation, upsert HotelStay with hotelId (+ optional dates), preserving existing roomNumber. Audit HOTEL_TEAM_ASSIGN.
  • unassignStay({ attendingMemberId })deleteMany (no-op safe). Audit HOTEL_STAY_UNASSIGN.

Then update setFlightStatus (the TRAVEL_CONFIRMED path, ~line 231): instead of the program 1:1 hotel, load the attendee's hotelStay (with hotel) and pass hotel + room/dates into the notification metadata (keys: hotel: { name, address, link }, roomNumber, checkInAt, checkOutAt). Update getTravelConfirmedTemplate (src/lib/email.ts) + its NOTIFICATION_EMAIL_TEMPLATES entry to render room/dates if present (additive — keep existing fields working).

  • Step 1: Tests (tests/unit/logistics-hotels.test.ts, mirror logistics-hotel.test.ts): create 2 hotels for one program; deleteHotel on an occupied hotel rejects, on an empty one succeeds; assignStay upsert (create→update room); assignTeamToHotel assigns all of a 2-attendee team; unassignStay removes; listRooming returns the confirmed attendees with/without stays.
  • Step 2: Run → fail → implement → pass (npx vitest run tests/unit/logistics-hotels.test.ts). Re-run tests/unit/logistics-flight.test.ts tests/unit/logistics-comms.test.ts (travel-email change).
  • Step 3: npm run typecheck. Commit: feat(logistics): hotels CRUD + rooming + assignment procedures + travel email

Task 3: Applicant getMyLogistics + My Logistics card → assigned hotel/room

Files: src/server/routers/applicant.ts, src/components/applicant/my-logistics-card.tsx; test tests/unit/applicant-my-logistics.test.ts (extend).

  • Step 1: In getMyLogistics (~line 2899), replace the prisma.hotel.findUnique({ where: { programId } }) with the caller's AttendingMember.hotelStay (include hotel). Return hotel: { name, address, link, notes } | null and room: { roomNumber, checkInAt, checkOutAt } | null. Extend the test to seed a HotelStay and assert hotel.name + room.roomNumber.
  • Step 2: Update MyLogisticsCard Hotel section to also show Room (number) + check-in/out (Monaco-time labels) when room is present.
  • Step 3: Run the applicant test → pass; npm run typecheck. Commit: feat(applicant): My Logistics shows assigned hotel + room

Task 4: Hotels tab rework — Hotels + Rooming UI

Files: src/components/admin/logistics/hotels-tab.tsx (rework); optionally split a rooming-section.tsx.

  • Step 1: Hotels sectiontrpc.logistics.listHotels; list cards with name/address/link/notes + occupancy badge (_count.stays); Add / Edit (dialog) / Delete (AlertDialog; show the "reassign first" error toast on rejection) wired to create/update/deleteHotel. Invalidate on success.
  • Step 2: Rooming sectiontrpc.logistics.listRooming; group rows by project (team). Per team header: an "Assign whole team to…" Select → assignTeamToHotel. Per attendee row: Hotel Select (→ assignStay, or unassignStay when cleared), Room # input (blur → assignStay), Check-in / Check-out date inputs (blur → assignStay). Hotel options from listHotels. Skeleton while loading; empty state "No confirmed attendees yet." Download CSV button (Team, Member, Email, Hotel, Room, Check-in, Check-out) mirroring the travel/visa export. Visible affordances only.
  • Step 3: npm run typecheck. Commit: feat(logistics): Hotels tab — multi-hotel management + rooming assignment

Task 5: Verify + deploy

  • Step 1: npx vitest run — full suite green; npm run typecheck clean.
  • Step 2: Stop dev, rm -rf .next, npm run build — clean.
  • Step 3: Restart dev on :3001. Dev smoke (admin): Logistics → Hotels tab → add 2 hotels; enroll a team (ADMIN_CONFIRM) so attendees exist; in Rooming, "assign whole team" to hotel A, override one member to hotel B + a room number; verify occupancy counts; check the team-member dashboard shows their assigned hotel + room. Clean up.
  • Step 4: Merge to main (fast-forward if possible), push, watch Gitea build #N succeed, then redeploy on prod (ssh stefan@89.58.5.223:22022, /opt/letsbe/stacks/mopc-portal, docker compose pull && docker compose down && docker compose up -dNO -v), verify migration applied + app healthy + GET /login 200.
  • Step 5: Summarize.

Notes

  • All comms/assignment writes best-effort where they sit inside other mutations (travel email try/catch already in place).
  • Prod migration is additive + one dropped unique constraint (safe; no HotelStay data exists yet).