Compare commits

..

7 Commits

Author SHA1 Message Date
Matt
89ab5cc8e6 test(logistics): remove obsolete single-hotel test (superseded by logistics-hotels)
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m20s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:25:41 +02:00
Matt
3bbc80332c feat(logistics): Hotels tab — multi-hotel management + rooming assignment
Rewrites hotels-tab.tsx from the removed single-hotel getHotel/upsertHotel
pattern to the new multi-hotel API: Hotels section (list/add/edit/delete with
occupancy badge) + Rooming section (per-attendee hotel+room+dates assignment,
team-assign shortcut, CSV export).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 19:24:06 +02:00
Matt
9313eb96f0 feat(applicant): My Logistics shows assigned hotel + room
Replace the program-level hotel.findUnique (broken after removing @unique)
with the caller's HotelStay (include hotel) on their AttendingMember.
Returns hotel: {name,address,link,notes}|null and room: {roomNumber,
checkInAt,checkOutAt}|null. MyLogisticsCard renders the Room section
(number + Monaco-time check-in/out) when room is present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 19:21:29 +02:00
Matt
4cd2651f9c feat(logistics): hotels CRUD + rooming + assignment procedures + travel email
Replace getHotel/upsertHotel with listHotels/createHotel/updateHotel/deleteHotel
(multi-hotel per edition). Add listRooming, assignStay, assignTeamToHotel, and
unassignStay procedures for per-attendee room assignments. Update setFlightStatus
to include attendee's HotelStay in TRAVEL_CONFIRMED notification metadata.
Extend getTravelConfirmedTemplate to render room number and check-in/out dates.
All procedures are adminProcedure and audit-logged. 10 new unit tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 19:19:18 +02:00
Matt
75e63eb47f feat(hotel): many hotels per edition + HotelStay (room assignment)
- Hotel.programId no longer unique (many hotels per edition)
- New HotelStay 1:1 with AttendingMember (hotelId, room, check-in/out)
- Program.hotel -> hotels[]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:13:52 +02:00
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
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
12 changed files with 1816 additions and 274 deletions

View File

@@ -0,0 +1,76 @@
# 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 section**`trpc.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 section**`trpc.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 -d`**NO -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).

View File

@@ -0,0 +1,108 @@
# 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.

View File

@@ -0,0 +1,33 @@
-- DropIndex
DROP INDEX "Hotel_programId_key";
-- CreateTable
CREATE TABLE "HotelStay" (
"id" TEXT NOT NULL,
"attendingMemberId" TEXT NOT NULL,
"hotelId" TEXT NOT NULL,
"roomNumber" TEXT,
"checkInAt" TIMESTAMP(3),
"checkOutAt" TIMESTAMP(3),
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HotelStay_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "HotelStay_attendingMemberId_key" ON "HotelStay"("attendingMemberId");
-- CreateIndex
CREATE INDEX "HotelStay_hotelId_idx" ON "HotelStay"("hotelId");
-- CreateIndex
CREATE INDEX "Hotel_programId_idx" ON "Hotel"("programId");
-- AddForeignKey
ALTER TABLE "HotelStay" ADD CONSTRAINT "HotelStay_attendingMemberId_fkey" FOREIGN KEY ("attendingMemberId") REFERENCES "AttendingMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HotelStay" ADD CONSTRAINT "HotelStay_hotelId_fkey" FOREIGN KEY ("hotelId") REFERENCES "Hotel"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -506,7 +506,7 @@ model Program {
// Grand-finale logistics // Grand-finale logistics
finalistSlotQuotas FinalistSlotQuota[] finalistSlotQuotas FinalistSlotQuota[]
waitlistEntries WaitlistEntry[] waitlistEntries WaitlistEntry[]
hotel Hotel? hotels Hotel[]
lunchEvent LunchEvent? lunchEvent LunchEvent?
@@unique([name, year]) @@unique([name, year])
@@ -2786,6 +2786,7 @@ model AttendingMember {
flightDetail FlightDetail? flightDetail FlightDetail?
visaApplication VisaApplication? visaApplication VisaApplication?
lunchPick MemberLunchPick? lunchPick MemberLunchPick?
hotelStay HotelStay?
@@unique([confirmationId, userId]) @@unique([confirmationId, userId])
@@index([userId]) @@index([userId])
@@ -2802,7 +2803,7 @@ enum FlightDetailStatus {
model Hotel { model Hotel {
id String @id @default(cuid()) id String @id @default(cuid())
programId String @unique // 1:1 — one hotel per edition programId String // many hotels per edition
name String name String
address String? @db.Text address String? @db.Text
link String? // external URL to hotel page / booking confirmation link String? // external URL to hotel page / booking confirmation
@@ -2811,6 +2812,27 @@ model Hotel {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
program Program @relation(fields: [programId], references: [id], onDelete: Cascade) program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
stays HotelStay[]
@@index([programId])
}
/// Per-attendee hotel/room assignment (1:1 with AttendingMember, mirrors FlightDetail).
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])
} }
model FlightDetail { model FlightDetail {

View File

@@ -1,83 +1,199 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { ExternalLink, Hotel as HotelIcon, Loader2, Save } from 'lucide-react' import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Download, ExternalLink, Hotel as HotelIcon, Loader2, Plus, Trash2, Pencil } from 'lucide-react'
interface Props { interface Props {
programId: string programId: string
} }
export function HotelsTab({ programId }: Props) { // ─── Types ────────────────────────────────────────────────────────────────────
const utils = trpc.useUtils()
const { data: hotel, isLoading } = trpc.logistics.getHotel.useQuery({ programId })
type HotelRow = {
id: string
name: string
address: string | null
link: string | null
notes: string | null
_count: { stays: number }
}
type RoomingRow = {
attendingMemberId: string
confirmationId: string
projectId: string
projectTitle: string
user: { id: string; name: string | null; email: string }
stay: { hotelId: string; roomNumber: string | null; checkInAt: Date | null; checkOutAt: Date | null } | null
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function toDateInputValue(d: Date | null | undefined): string {
if (!d) return ''
const dt = new Date(d)
if (Number.isNaN(dt.getTime())) return ''
return dt.toISOString().slice(0, 10)
}
function fromDateInputValue(s: string): Date | null {
if (!s) return null
const dt = new Date(s)
return Number.isNaN(dt.getTime()) ? null : dt
}
function csvEscape(value: string | null | undefined): string {
const str = value ?? ''
if (str.includes('"') || str.includes(',') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
function buildRoomingCsv(rows: RoomingRow[], hotels: HotelRow[]): string {
const hotelMap = new Map(hotels.map((h) => [h.id, h.name]))
const header = ['Team', 'Member', 'Email', 'Hotel', 'Room', 'Check-in', 'Check-out'].join(',')
const lines = rows.map((r) => {
const s = r.stay
return [
csvEscape(r.projectTitle),
csvEscape(r.user.name ?? r.user.email),
csvEscape(r.user.email),
csvEscape(s ? (hotelMap.get(s.hotelId) ?? '') : ''),
csvEscape(s?.roomNumber ?? ''),
csvEscape(s?.checkInAt ? toDateInputValue(s.checkInAt) : ''),
csvEscape(s?.checkOutAt ? toDateInputValue(s.checkOutAt) : ''),
].join(',')
})
return [header, ...lines].join('\r\n')
}
// ─── Hotel Form Dialog ────────────────────────────────────────────────────────
type HotelFormMode = { type: 'create' } | { type: 'edit'; hotel: HotelRow }
function HotelFormDialog({
open,
mode,
programId,
onOpenChange,
}: {
open: boolean
mode: HotelFormMode
programId: string
onOpenChange: (next: boolean) => void
}) {
const utils = trpc.useUtils()
const [name, setName] = useState('') const [name, setName] = useState('')
const [address, setAddress] = useState('') const [address, setAddress] = useState('')
const [link, setLink] = useState('') const [link, setLink] = useState('')
const [notes, setNotes] = useState('') const [notes, setNotes] = useState('')
// Sync form state from server data on first load / after save.
useEffect(() => { useEffect(() => {
if (hotel) { if (!open) return
setName(hotel.name) if (mode.type === 'edit') {
setAddress(hotel.address ?? '') setName(mode.hotel.name)
setLink(hotel.link ?? '') setAddress(mode.hotel.address ?? '')
setNotes(hotel.notes ?? '') setLink(mode.hotel.link ?? '')
setNotes(mode.hotel.notes ?? '')
} else {
setName('')
setAddress('')
setLink('')
setNotes('')
} }
}, [hotel]) }, [open, mode])
const upsertMutation = trpc.logistics.upsertHotel.useMutation({ const onSuccess = () => {
onSuccess: () => { toast.success(mode.type === 'create' ? 'Hotel added' : 'Hotel updated')
toast.success('Hotel saved') utils.logistics.listHotels.invalidate({ programId })
utils.logistics.getHotel.invalidate({ programId }) onOpenChange(false)
}, }
const createMutation = trpc.logistics.createHotel.useMutation({
onSuccess,
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
const updateMutation = trpc.logistics.updateHotel.useMutation({
onSuccess,
onError: (err) => toast.error(err.message),
})
const isPending = createMutation.isPending || updateMutation.isPending
const handleSave = () => { const handleSave = () => {
if (!name.trim()) { if (!name.trim()) {
toast.error('Hotel name is required') toast.error('Hotel name is required')
return return
} }
upsertMutation.mutate({ if (mode.type === 'create') {
createMutation.mutate({
programId, programId,
name: name.trim(), name: name.trim(),
address: address.trim() || undefined, address: address.trim() || undefined,
link: link.trim() || '', link: link.trim() || undefined,
notes: notes.trim() || undefined, notes: notes.trim() || undefined,
}) })
} else {
updateMutation.mutate({
id: mode.hotel.id,
name: name.trim(),
address: address.trim() || null,
link: link.trim() || null,
notes: notes.trim() || null,
})
}
} }
if (isLoading) return <Skeleton className="h-96 w-full" />
const dirty =
name !== (hotel?.name ?? '') ||
address !== (hotel?.address ?? '') ||
link !== (hotel?.link ?? '') ||
notes !== (hotel?.notes ?? '')
return ( return (
<div className="grid gap-4 md:grid-cols-3"> <Dialog open={open} onOpenChange={(next) => { if (!isPending) onOpenChange(next) }}>
<div className="md:col-span-2"> <DialogContent className="sm:max-w-md">
<Card> <DialogHeader>
<CardHeader> <DialogTitle>{mode.type === 'create' ? 'Add hotel' : 'Edit hotel'}</DialogTitle>
<div className="flex items-center gap-2"> <DialogDescription>
<HotelIcon className="text-muted-foreground h-4 w-4" /> {mode.type === 'create'
<CardTitle className="text-base">Hotel for this edition</CardTitle> ? 'Add a hotel that finalists can be assigned to.'
</div> : 'Update hotel details.'}
<CardDescription> </DialogDescription>
One hotel per edition. Used in confirmation emails and finalist communications. </DialogHeader>
</CardDescription>
</CardHeader> <div className="space-y-4">
<CardContent className="space-y-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="hotel-name">Name *</Label> <Label htmlFor="hotel-name">Name *</Label>
<Input <Input
@@ -99,7 +215,7 @@ export function HotelsTab({ programId }: Props) {
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="hotel-link">Hotel website / booking link</Label> <Label htmlFor="hotel-link">Website / booking link</Label>
<Input <Input
id="hotel-link" id="hotel-link"
type="url" type="url"
@@ -118,58 +234,463 @@ export function HotelsTab({ programId }: Props) {
rows={3} rows={3}
/> />
</div> </div>
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={!dirty || upsertMutation.isPending}
>
{upsertMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save
</Button>
</div>
</CardContent>
</Card>
</div> </div>
<div className="md:col-span-1"> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{mode.type === 'create' ? 'Add hotel' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ─── Hotels Section ───────────────────────────────────────────────────────────
function HotelsSection({ programId }: { programId: string }) {
const utils = trpc.useUtils()
const { data: hotels, isLoading } = trpc.logistics.listHotels.useQuery({ programId })
const [dialogOpen, setDialogOpen] = useState(false)
const [dialogMode, setDialogMode] = useState<HotelFormMode>({ type: 'create' })
const deleteMutation = trpc.logistics.deleteHotel.useMutation({
onSuccess: () => {
toast.success('Hotel removed')
utils.logistics.listHotels.invalidate({ programId })
utils.logistics.listRooming.invalidate({ programId })
},
onError: (err) => toast.error(err.message),
})
const openCreate = () => {
setDialogMode({ type: 'create' })
setDialogOpen(true)
}
const openEdit = (hotel: HotelRow) => {
setDialogMode({ type: 'edit', hotel })
setDialogOpen(true)
}
return (
<>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Email preview</CardTitle> <div className="flex items-center justify-between gap-4">
<CardDescription>What teams will see in confirmation emails.</CardDescription> <div className="flex items-center gap-2">
<HotelIcon className="text-muted-foreground h-4 w-4" />
<CardTitle className="text-base">Hotels</CardTitle>
</div>
<Button size="sm" onClick={openCreate}>
<Plus className="mr-1 h-4 w-4" />
Add hotel
</Button>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{!name.trim() ? ( {isLoading ? (
<p className="text-muted-foreground text-sm">Save a hotel to see the preview.</p> <div className="space-y-2">
{[1, 2].map((i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : !hotels || hotels.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
No hotels yet. Add one above.
</p>
) : ( ) : (
<div className="bg-muted/30 rounded-md border p-4 text-sm"> <div className="space-y-3">
<div className="text-muted-foreground mb-1 text-xs uppercase tracking-wide"> {hotels.map((hotel) => (
Your accommodation <div
</div> key={hotel.id}
<div className="font-semibold">{name}</div> className="flex items-start justify-between gap-4 rounded-md border p-3"
{address.trim() && ( >
<div className="text-muted-foreground mt-1 whitespace-pre-line text-xs"> <div className="min-w-0 flex-1 space-y-1">
{address} <div className="flex items-center gap-2">
<span className="font-medium">{hotel.name}</span>
<Badge variant="secondary" className="text-xs">
{hotel._count.stays} guest{hotel._count.stays !== 1 ? 's' : ''}
</Badge>
</div> </div>
{hotel.address && (
<p className="text-muted-foreground text-xs whitespace-pre-line">{hotel.address}</p>
)} )}
{link.trim() && ( {hotel.link && (
<a <a
href={link} href={hotel.link}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary mt-2 inline-flex items-center gap-1 text-xs hover:underline" className="text-primary inline-flex items-center gap-1 text-xs hover:underline"
> >
Visit hotel website <ExternalLink className="h-3 w-3" /> Visit website <ExternalLink className="h-3 w-3" />
</a> </a>
)} )}
{hotel.notes && (
<p className="text-muted-foreground text-xs italic">{hotel.notes}</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => openEdit(hotel)}
>
<Pencil className="h-4 w-4" />
<span className="sr-only">Edit</span>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive h-8 w-8"
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete hotel?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove <strong>{hotel.name}</strong>.
{hotel._count.stays > 0
? ` Reassign the ${hotel._count.stays} guest(s) before deleting.`
: ' This action cannot be undone.'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteMutation.mutate({ id: hotel.id })}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
))}
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
<HotelFormDialog
open={dialogOpen}
mode={dialogMode}
programId={programId}
onOpenChange={setDialogOpen}
/>
</>
)
}
// ─── Attendee Row ─────────────────────────────────────────────────────────────
function AttendeeRoomRow({
row,
hotels,
programId,
}: {
row: RoomingRow
hotels: HotelRow[]
programId: string
}) {
const utils = trpc.useUtils()
const [roomNumber, setRoomNumber] = useState(row.stay?.roomNumber ?? '')
const [checkIn, setCheckIn] = useState(toDateInputValue(row.stay?.checkInAt ?? null))
const [checkOut, setCheckOut] = useState(toDateInputValue(row.stay?.checkOutAt ?? null))
// Keep local state in sync when server data updates
const prevStayRef = useRef(row.stay)
useEffect(() => {
const prev = prevStayRef.current
const cur = row.stay
// Only sync if the stay changed from outside (different hotelId or null/non-null)
if (prev?.hotelId !== cur?.hotelId || (prev === null) !== (cur === null)) {
setRoomNumber(cur?.roomNumber ?? '')
setCheckIn(toDateInputValue(cur?.checkInAt ?? null))
setCheckOut(toDateInputValue(cur?.checkOutAt ?? null))
}
prevStayRef.current = cur
}, [row.stay])
const assignMutation = trpc.logistics.assignStay.useMutation({
onSuccess: () => utils.logistics.listRooming.invalidate({ programId }),
onError: (err) => toast.error(err.message),
})
const unassignMutation = trpc.logistics.unassignStay.useMutation({
onSuccess: () => utils.logistics.listRooming.invalidate({ programId }),
onError: (err) => toast.error(err.message),
})
const currentHotelId = row.stay?.hotelId ?? ''
const handleHotelChange = (value: string) => {
if (!value) {
unassignMutation.mutate({ attendingMemberId: row.attendingMemberId })
} else {
assignMutation.mutate({
attendingMemberId: row.attendingMemberId,
hotelId: value,
roomNumber: roomNumber.trim() || null,
checkInAt: fromDateInputValue(checkIn),
checkOutAt: fromDateInputValue(checkOut),
})
}
}
const commitRoomNumber = () => {
if (!currentHotelId) return
const trimmed = roomNumber.trim()
if (trimmed === (row.stay?.roomNumber ?? '')) return
assignMutation.mutate({
attendingMemberId: row.attendingMemberId,
hotelId: currentHotelId,
roomNumber: trimmed || null,
checkInAt: fromDateInputValue(checkIn),
checkOutAt: fromDateInputValue(checkOut),
})
}
const commitCheckIn = () => {
if (!currentHotelId) return
if (checkIn === toDateInputValue(row.stay?.checkInAt ?? null)) return
assignMutation.mutate({
attendingMemberId: row.attendingMemberId,
hotelId: currentHotelId,
roomNumber: roomNumber.trim() || null,
checkInAt: fromDateInputValue(checkIn),
checkOutAt: fromDateInputValue(checkOut),
})
}
const commitCheckOut = () => {
if (!currentHotelId) return
if (checkOut === toDateInputValue(row.stay?.checkOutAt ?? null)) return
assignMutation.mutate({
attendingMemberId: row.attendingMemberId,
hotelId: currentHotelId,
roomNumber: roomNumber.trim() || null,
checkInAt: fromDateInputValue(checkIn),
checkOutAt: fromDateInputValue(checkOut),
})
}
const isBusy = assignMutation.isPending || unassignMutation.isPending
const hasHotel = !!currentHotelId
return (
<div className="grid grid-cols-[1fr_auto] items-center gap-2 py-2 pl-4 sm:grid-cols-[2fr_2fr_1fr_1fr_1fr]">
{/* Member */}
<div className="col-span-2 sm:col-span-1">
<div className="text-sm font-medium">{row.user.name ?? row.user.email}</div>
<div className="text-muted-foreground text-xs">{row.user.email}</div>
</div> </div>
{/* Hotel select */}
<Select value={currentHotelId} onValueChange={handleHotelChange} disabled={isBusy}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="— Unassigned —" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""> Unassigned </SelectItem>
{hotels.map((h) => (
<SelectItem key={h.id} value={h.id}>
{h.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Room # */}
<Input
className="h-8 text-xs"
placeholder="Room #"
value={roomNumber}
onChange={(e) => setRoomNumber(e.target.value)}
onBlur={commitRoomNumber}
disabled={!hasHotel || isBusy}
/>
{/* Check-in */}
<Input
className="h-8 text-xs"
type="date"
value={checkIn}
onChange={(e) => setCheckIn(e.target.value)}
onBlur={commitCheckIn}
disabled={!hasHotel || isBusy}
/>
{/* Check-out */}
<Input
className="h-8 text-xs"
type="date"
value={checkOut}
onChange={(e) => setCheckOut(e.target.value)}
onBlur={commitCheckOut}
disabled={!hasHotel || isBusy}
/>
</div>
)
}
// ─── Rooming Section ──────────────────────────────────────────────────────────
function RoomingSection({ programId }: { programId: string }) {
const utils = trpc.useUtils()
const { data: rooming, isLoading: roomingLoading } = trpc.logistics.listRooming.useQuery({ programId })
const { data: hotels } = trpc.logistics.listHotels.useQuery({ programId })
const assignTeamMutation = trpc.logistics.assignTeamToHotel.useMutation({
onSuccess: () => {
toast.success('Team assigned')
utils.logistics.listRooming.invalidate({ programId })
utils.logistics.listHotels.invalidate({ programId })
},
onError: (err) => toast.error(err.message),
})
// Group rows by projectTitle
const grouped = useMemo(() => {
if (!rooming) return []
const map = new Map<string, { confirmationId: string; projectTitle: string; rows: RoomingRow[] }>()
for (const row of rooming) {
if (!map.has(row.projectId)) {
map.set(row.projectId, {
confirmationId: row.confirmationId,
projectTitle: row.projectTitle,
rows: [],
})
}
map.get(row.projectId)!.rows.push(row)
}
return Array.from(map.values())
}, [rooming])
const downloadCsv = () => {
if (!rooming || !hotels) return
const csv = buildRoomingCsv(rooming, hotels)
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'rooming-manifest.csv'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-4">
<CardTitle className="text-base">Rooming</CardTitle>
<Button
variant="outline"
size="sm"
disabled={!rooming || rooming.length === 0}
onClick={downloadCsv}
>
<Download className="mr-1 h-4 w-4" /> Download CSV
</Button>
</div>
</CardHeader>
<CardContent>
{roomingLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : grouped.length === 0 ? (
<p className="text-muted-foreground py-12 text-center text-sm">
No confirmed attendees yet.
</p>
) : (
<div className="space-y-6">
{grouped.map((group) => (
<div key={group.confirmationId}>
{/* Team header */}
<div className="bg-muted/40 flex items-center justify-between gap-4 rounded-t-md border px-3 py-2">
<span className="text-sm font-semibold">{group.projectTitle}</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs shrink-0">Assign whole team to</span>
<Select
value=""
onValueChange={(hotelId) => {
if (!hotelId) return
assignTeamMutation.mutate({
confirmationId: group.confirmationId,
hotelId,
})
}}
disabled={assignTeamMutation.isPending || !hotels || hotels.length === 0}
>
<SelectTrigger className="h-7 w-40 text-xs">
<SelectValue placeholder="Select hotel…" />
</SelectTrigger>
<SelectContent>
{(hotels ?? []).map((h) => (
<SelectItem key={h.id} value={h.id}>
{h.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Column headers */}
<div className="hidden grid-cols-[2fr_2fr_1fr_1fr_1fr] gap-2 border-x border-b bg-white px-4 py-1 sm:grid">
<span className="text-muted-foreground text-xs font-medium">Member</span>
<span className="text-muted-foreground text-xs font-medium">Hotel</span>
<span className="text-muted-foreground text-xs font-medium">Room #</span>
<span className="text-muted-foreground text-xs font-medium">Check-in</span>
<span className="text-muted-foreground text-xs font-medium">Check-out</span>
</div>
{/* Attendee rows */}
<div className="divide-y rounded-b-md border-x border-b">
{group.rows.map((row) => (
<AttendeeRoomRow
key={row.attendingMemberId}
row={row}
hotels={hotels ?? []}
programId={programId}
/>
))}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}
// ─── Main export ──────────────────────────────────────────────────────────────
export function HotelsTab({ programId }: Props) {
return (
<div className="space-y-6">
<HotelsSection programId={programId} />
<RoomingSection programId={programId} />
</div> </div>
) )
} }

View File

@@ -175,7 +175,7 @@ export function MyLogisticsCard() {
if (!data) return null if (!data) return null
const { hotel, myFlight, visaVisible, myVisa } = data const { hotel, room, myFlight, visaVisible, myVisa } = data
const hasFlightData = const hasFlightData =
myFlight && myFlight &&
@@ -227,6 +227,38 @@ export function MyLogisticsCard() {
)} )}
</div> </div>
{/* Room */}
{room && (
<div className="space-y-1">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Room
</p>
<div className="space-y-0.5">
{room.roomNumber && (
<p className="text-sm font-medium">Room {room.roomNumber}</p>
)}
{room.checkInAt && (
<p className="text-muted-foreground text-sm">
Check-in:{' '}
<span className="text-foreground">
{formatMonacoTime(room.checkInAt)}
</span>{' '}
<span className="text-xs text-muted-foreground">(Monaco time)</span>
</p>
)}
{room.checkOutAt && (
<p className="text-muted-foreground text-sm">
Check-out:{' '}
<span className="text-foreground">
{formatMonacoTime(room.checkOutAt)}
</span>{' '}
<span className="text-xs text-muted-foreground">(Monaco time)</span>
</p>
)}
</div>
</div>
)}
{/* Flights */} {/* Flights */}
<div className="space-y-2"> <div className="space-y-2">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide"> <p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">

View File

@@ -2297,6 +2297,11 @@ function getTravelConfirmedTemplate(
address?: string | null address?: string | null
link?: string | null link?: string | null
}, },
room?: {
roomNumber?: string | null
checkInAt?: string | null
checkOutAt?: string | null
},
): EmailTemplate { ): EmailTemplate {
const greeting = name ? `Hi ${name},` : 'Hi there,' const greeting = name ? `Hi ${name},` : 'Hi there,'
@@ -2333,18 +2338,38 @@ function getTravelConfirmedTemplate(
`</ul>` `</ul>`
: '' : ''
const fmtDateShort = (d: string | null | undefined) => {
if (!d) return null
const dt = new Date(d)
return dt.toLocaleString('en-GB', { timeZone: 'Europe/Paris', dateStyle: 'long', timeStyle: 'short' })
}
const roomLines: string[] = []
if (room?.roomNumber) roomLines.push(`Room: ${room.roomNumber}`)
if (room?.checkInAt) roomLines.push(`Check-in: ${fmtDateShort(room.checkInAt)} (Paris time)`)
if (room?.checkOutAt) roomLines.push(`Check-out: ${fmtDateShort(room.checkOutAt)} (Paris time)`)
const roomHtml =
roomLines.length > 0
? `<ul style="margin:8px 0 0;padding-left:20px;color:${BRAND.textDark};font-size:14px;">` +
roomLines.map((l) => `<li style="margin:4px 0;">${escapeHtml(l)}</li>`).join('') +
`</ul>`
: ''
const hotelHtml = hotel const hotelHtml = hotel
? `<h3 style="margin:20px 0 8px;color:#0f172a;font-size:14px;font-weight:600;text-transform:uppercase;letter-spacing:1px;">Hotel</h3>` + ? `<h3 style="margin:20px 0 8px;color:#0f172a;font-size:14px;font-weight:600;text-transform:uppercase;letter-spacing:1px;">Hotel</h3>` +
infoBox( infoBox(
`<strong>${escapeHtml(hotel.name)}</strong>` + `<strong>${escapeHtml(hotel.name)}</strong>` +
(hotel.address ? `<br>${escapeHtml(hotel.address)}` : '') + (hotel.address ? `<br>${escapeHtml(hotel.address)}` : '') +
(hotel.link ? `<br><a href="${hotel.link}" style="color:${BRAND.darkBlue};">View hotel</a>` : ''), (hotel.link ? `<br><a href="${hotel.link}" style="color:${BRAND.darkBlue};">View hotel</a>` : '') +
roomHtml,
'info', 'info',
) )
: '' : ''
const roomTextLines = roomLines.length > 0 ? '\n' + roomLines.map((l) => ` ${l}`).join('\n') : ''
const hotelText = hotel const hotelText = hotel
? ['\nHotel:', ` ${hotel.name}`, ...(hotel.address ? [` ${hotel.address}`] : []), ...(hotel.link ? [` ${hotel.link}`] : [])].join('\n') ? ['\nHotel:', ` ${hotel.name}`, ...(hotel.address ? [` ${hotel.address}`] : []), ...(hotel.link ? [` ${hotel.link}`] : []), roomTextLines].join('\n')
: '' : ''
const content = ` const content = `
@@ -2679,6 +2704,13 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
departureAirport: ctx.metadata?.departureAirport as string | undefined, departureAirport: ctx.metadata?.departureAirport as string | undefined,
}, },
ctx.metadata?.hotel as { name: string; address?: string; link?: string } | undefined, ctx.metadata?.hotel as { name: string; address?: string; link?: string } | undefined,
ctx.metadata?.roomNumber !== undefined || ctx.metadata?.checkInAt !== undefined || ctx.metadata?.checkOutAt !== undefined
? {
roomNumber: ctx.metadata?.roomNumber as string | null | undefined,
checkInAt: ctx.metadata?.checkInAt as string | null | undefined,
checkOutAt: ctx.metadata?.checkOutAt as string | null | undefined,
}
: undefined,
), ),
VISA_STATUS_UPDATE: (ctx) => VISA_STATUS_UPDATE: (ctx) =>
getVisaStatusTemplate( getVisaStatusTemplate(

View File

@@ -2895,22 +2895,33 @@ export const applicantRouter = router({
const confirmationId = project.finalistConfirmation.id const confirmationId = project.finalistConfirmation.id
const visaVisible = project.program.visaStatusVisibleToMembers const visaVisible = project.program.visaStatusVisibleToMembers
// Hotel (1:1 per program) // Caller's own AttendingMember + hotel stay + flight + visa
const hotelRow = await ctx.prisma.hotel.findUnique({
where: { programId },
select: { name: true, address: true, link: true, notes: true },
})
const hotel = hotelRow ?? null
// Caller's own AttendingMember + flight + visa
const attendee = await ctx.prisma.attendingMember.findFirst({ const attendee = await ctx.prisma.attendingMember.findFirst({
where: { confirmationId, userId: ctx.user.id }, where: { confirmationId, userId: ctx.user.id },
include: { include: {
hotelStay: { include: { hotel: true } },
flightDetail: true, flightDetail: true,
visaApplication: visaVisible, visaApplication: visaVisible,
}, },
}) })
const stay = attendee?.hotelStay ?? null
const hotel = stay?.hotel
? {
name: stay.hotel.name,
address: stay.hotel.address,
link: stay.hotel.link,
notes: stay.hotel.notes,
}
: null
const room = stay
? {
roomNumber: stay.roomNumber,
checkInAt: stay.checkInAt,
checkOutAt: stay.checkOutAt,
}
: null
const fd = attendee?.flightDetail ?? null const fd = attendee?.flightDetail ?? null
const myFlight = fd const myFlight = fd
? { ? {
@@ -2931,6 +2942,7 @@ export const applicantRouter = router({
projectTitle: project.title, projectTitle: project.title,
confirmationStatus: project.finalistConfirmation.status, confirmationStatus: project.finalistConfirmation.status,
hotel, hotel,
room,
myFlight, myFlight,
visaVisible, visaVisible,
myVisa, myVisa,

View File

@@ -6,15 +6,23 @@ import { logAudit } from '../utils/audit'
import { createNotification, NotificationTypes } from '../services/in-app-notification' import { createNotification, NotificationTypes } from '../services/in-app-notification'
export const logisticsRouter = router({ export const logisticsRouter = router({
/** Read the hotel for a program (1:1). Null if not yet set. */ // ─────────────────────────────────────────────────────────────────────────
getHotel: adminProcedure // Hotels CRUD (multi-hotel per edition)
// ─────────────────────────────────────────────────────────────────────────
/** List all hotels for an edition, including occupancy count. */
listHotels: adminProcedure
.input(z.object({ programId: z.string() })) .input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
return ctx.prisma.hotel.findUnique({ where: { programId: input.programId } }) return ctx.prisma.hotel.findMany({
where: { programId: input.programId },
include: { _count: { select: { stays: true } } },
orderBy: { name: 'asc' },
})
}), }),
/** Create or update the program's hotel. Empty link strings are stored as null. */ /** Create a new hotel for the edition. Empty link strings are stored as null. */
upsertHotel: adminProcedure createHotel: adminProcedure
.input( .input(
z.object({ z.object({
programId: z.string(), programId: z.string(),
@@ -30,26 +38,19 @@ export const logisticsRouter = router({
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const link = input.link && input.link.trim().length > 0 ? input.link : null const link = input.link && input.link.trim().length > 0 ? input.link : null
const hotel = await ctx.prisma.hotel.upsert({ const hotel = await ctx.prisma.hotel.create({
where: { programId: input.programId }, data: {
create: {
programId: input.programId, programId: input.programId,
name: input.name, name: input.name,
address: input.address ?? null, address: input.address ?? null,
link, link,
notes: input.notes ?? null, notes: input.notes ?? null,
}, },
update: {
name: input.name,
address: input.address ?? null,
link,
notes: input.notes ?? null,
},
}) })
await logAudit({ await logAudit({
prisma: ctx.prisma, prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'HOTEL_UPSERT', action: 'HOTEL_CREATE',
entityType: 'Hotel', entityType: 'Hotel',
entityId: hotel.id, entityId: hotel.id,
detailsJson: { programId: input.programId, name: input.name }, detailsJson: { programId: input.programId, name: input.name },
@@ -57,6 +58,301 @@ export const logisticsRouter = router({
return hotel return hotel
}), }),
/** Update an existing hotel's fields. Empty link strings are stored as null. */
updateHotel: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).max(200),
address: z.string().max(500).optional().nullable(),
link: z
.string()
.url()
.or(z.literal(''))
.optional()
.nullable(),
notes: z.string().max(2000).optional().nullable(),
}),
)
.mutation(async ({ ctx, input }) => {
const link =
input.link !== undefined
? input.link && input.link.trim().length > 0
? input.link
: null
: undefined
const hotel = await ctx.prisma.hotel.update({
where: { id: input.id },
data: {
name: input.name,
...(input.address !== undefined ? { address: input.address } : {}),
...(link !== undefined ? { link } : {}),
...(input.notes !== undefined ? { notes: input.notes } : {}),
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'HOTEL_UPDATE',
entityType: 'Hotel',
entityId: hotel.id,
detailsJson: { id: input.id, name: input.name },
})
return hotel
}),
/**
* Delete a hotel. Rejected with BAD_REQUEST if any HotelStay rows reference it
* (the user must reassign occupants first).
*/
deleteHotel: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const occupantCount = await ctx.prisma.hotelStay.count({
where: { hotelId: input.id },
})
if (occupantCount > 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Reassign ${occupantCount} occupant(s) before deleting this hotel.`,
})
}
const hotel = await ctx.prisma.hotel.delete({ where: { id: input.id } })
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'HOTEL_DELETE',
entityType: 'Hotel',
entityId: hotel.id,
detailsJson: { id: input.id, name: hotel.name },
})
return { success: true }
}),
// ─────────────────────────────────────────────────────────────────────────
// Rooming — per-attendee hotel/room assignment
// ─────────────────────────────────────────────────────────────────────────
/**
* One row per CONFIRMED AttendingMember in the program.
* Each row includes their hotelStay (null when not yet assigned).
* Sorted by project title then user name.
*/
listRooming: adminProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const rows = await ctx.prisma.attendingMember.findMany({
where: {
confirmation: {
status: 'CONFIRMED',
project: { programId: input.programId },
},
},
select: {
id: true,
user: { select: { id: true, name: true, email: true } },
confirmation: {
select: {
id: true,
project: { select: { id: true, title: true } },
},
},
hotelStay: {
select: { hotelId: true, roomNumber: true, checkInAt: true, checkOutAt: true },
},
},
orderBy: [
{ confirmation: { project: { title: 'asc' } } },
{ user: { name: 'asc' } },
],
})
return rows.map((r) => ({
attendingMemberId: r.id,
confirmationId: r.confirmation.id,
projectId: r.confirmation.project.id,
projectTitle: r.confirmation.project.title,
user: r.user,
stay: r.hotelStay ?? null,
}))
}),
/**
* Upsert an individual attendee's hotel/room assignment.
* Validates that the hotel belongs to the same program as the attendee.
*/
assignStay: adminProcedure
.input(
z.object({
attendingMemberId: z.string(),
hotelId: z.string(),
roomNumber: z.string().max(50).optional().nullable(),
checkInAt: z.date().optional().nullable(),
checkOutAt: z.date().optional().nullable(),
notes: z.string().max(2000).optional().nullable(),
}),
)
.mutation(async ({ ctx, input }) => {
// Validate hotel belongs to the same program as the attendee
const [attendee, hotel] = await Promise.all([
ctx.prisma.attendingMember.findUnique({
where: { id: input.attendingMemberId },
select: {
confirmation: { select: { project: { select: { programId: true } } } },
},
}),
ctx.prisma.hotel.findUnique({
where: { id: input.hotelId },
select: { programId: true },
}),
])
if (!attendee || !hotel) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Attendee or hotel not found.' })
}
if (attendee.confirmation.project.programId !== hotel.programId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Hotel does not belong to the same program as the attendee.',
})
}
const data: Record<string, unknown> = {
hotelId: input.hotelId,
}
if (input.roomNumber !== undefined) data.roomNumber = input.roomNumber
if (input.checkInAt !== undefined) data.checkInAt = input.checkInAt
if (input.checkOutAt !== undefined) data.checkOutAt = input.checkOutAt
if (input.notes !== undefined) data.notes = input.notes
const stay = await ctx.prisma.hotelStay.upsert({
where: { attendingMemberId: input.attendingMemberId },
create: {
attendingMemberId: input.attendingMemberId,
hotelId: input.hotelId,
roomNumber: input.roomNumber ?? null,
checkInAt: input.checkInAt ?? null,
checkOutAt: input.checkOutAt ?? null,
notes: input.notes ?? null,
},
update: data,
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'HOTEL_STAY_ASSIGN',
entityType: 'HotelStay',
entityId: stay.id,
detailsJson: { attendingMemberId: input.attendingMemberId, hotelId: input.hotelId },
})
return stay
}),
/**
* Assign all AttendingMembers of a confirmation to a hotel (the "whole team" shortcut).
* Preserves each member's existing roomNumber on update; sets it to null on create.
* Validates the hotel belongs to the same program as the confirmation's project.
*/
assignTeamToHotel: adminProcedure
.input(
z.object({
confirmationId: z.string(),
hotelId: z.string(),
checkInAt: z.date().optional().nullable(),
checkOutAt: z.date().optional().nullable(),
}),
)
.mutation(async ({ ctx, input }) => {
// Validate program match
const [confirmation, hotel] = await Promise.all([
ctx.prisma.finalistConfirmation.findUnique({
where: { id: input.confirmationId },
select: {
project: { select: { programId: true } },
attendingMembers: { select: { id: true } },
},
}),
ctx.prisma.hotel.findUnique({
where: { id: input.hotelId },
select: { programId: true },
}),
])
if (!confirmation || !hotel) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Confirmation or hotel not found.' })
}
if (confirmation.project.programId !== hotel.programId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Hotel does not belong to the same program as this confirmation.',
})
}
// Upsert each attendee's stay, preserving existing roomNumber
const results = await Promise.all(
confirmation.attendingMembers.map(async (member) => {
const existing = await ctx.prisma.hotelStay.findUnique({
where: { attendingMemberId: member.id },
select: { roomNumber: true },
})
return ctx.prisma.hotelStay.upsert({
where: { attendingMemberId: member.id },
create: {
attendingMemberId: member.id,
hotelId: input.hotelId,
roomNumber: null,
checkInAt: input.checkInAt ?? null,
checkOutAt: input.checkOutAt ?? null,
},
update: {
hotelId: input.hotelId,
...(input.checkInAt !== undefined ? { checkInAt: input.checkInAt } : {}),
...(input.checkOutAt !== undefined ? { checkOutAt: input.checkOutAt } : {}),
// Preserve existing roomNumber (do not overwrite it)
...(existing?.roomNumber !== undefined ? {} : {}),
},
})
}),
)
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'HOTEL_TEAM_ASSIGN',
entityType: 'FinalistConfirmation',
entityId: input.confirmationId,
detailsJson: {
confirmationId: input.confirmationId,
hotelId: input.hotelId,
count: results.length,
},
})
return { count: results.length }
}),
/** Remove an attendee's hotel/room assignment. No-op safe (deleteMany). */
unassignStay: adminProcedure
.input(z.object({ attendingMemberId: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.hotelStay.deleteMany({
where: { attendingMemberId: input.attendingMemberId },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'HOTEL_STAY_UNASSIGN',
entityType: 'HotelStay',
entityId: input.attendingMemberId,
detailsJson: { attendingMemberId: input.attendingMemberId },
})
return { success: true }
}),
// ─────────────────────────────────────────────────────────────────────────
// Confirmations
// ─────────────────────────────────────────────────────────────────────────
/** /**
* Read-only listing of every FinalistConfirmation in a program, with the * Read-only listing of every FinalistConfirmation in a program, with the
* joined project + attendee count + decline reason. Sorted by status * joined project + attendee count + decline reason. Sorted by status
@@ -104,6 +400,10 @@ export const logisticsRouter = router({
}) })
}), }),
// ─────────────────────────────────────────────────────────────────────────
// Flight details
// ─────────────────────────────────────────────────────────────────────────
/** /**
* List all attending members for CONFIRMED finalists in a program, with * List all attending members for CONFIRMED finalists in a program, with
* their (optional) flight details. One row per attendee — even those * their (optional) flight details. One row per attendee — even those
@@ -213,12 +513,14 @@ export const logisticsRouter = router({
where: { id: detail.attendingMemberId }, where: { id: detail.attendingMemberId },
select: { select: {
userId: true, userId: true,
hotelStay: {
include: { hotel: { select: { name: true, address: true, link: true } } },
},
confirmation: { confirmation: {
select: { select: {
project: { project: {
select: { select: {
title: true, title: true,
programId: true,
}, },
}, },
}, },
@@ -227,11 +529,7 @@ export const logisticsRouter = router({
}) })
if (attendee) { if (attendee) {
const projectTitle = attendee.confirmation.project.title const projectTitle = attendee.confirmation.project.title
const programId = attendee.confirmation.project.programId const hotelStay = attendee.hotelStay
const hotel = await ctx.prisma.hotel.findUnique({
where: { programId },
select: { name: true, address: true, link: true },
})
await createNotification({ await createNotification({
userId: attendee.userId, userId: attendee.userId,
type: NotificationTypes.TRAVEL_CONFIRMED, type: NotificationTypes.TRAVEL_CONFIRMED,
@@ -246,7 +544,16 @@ export const logisticsRouter = router({
departureAt: detail.departureAt?.toISOString() ?? null, departureAt: detail.departureAt?.toISOString() ?? null,
departureFlightNumber: detail.departureFlightNumber ?? null, departureFlightNumber: detail.departureFlightNumber ?? null,
departureAirport: detail.departureAirport ?? null, departureAirport: detail.departureAirport ?? null,
hotel: hotel ?? undefined, hotel: hotelStay?.hotel
? {
name: hotelStay.hotel.name,
address: hotelStay.hotel.address ?? null,
link: hotelStay.hotel.link ?? null,
}
: null,
roomNumber: hotelStay?.roomNumber ?? null,
checkInAt: hotelStay?.checkInAt?.toISOString() ?? null,
checkOutAt: hotelStay?.checkOutAt?.toISOString() ?? null,
}, },
}) })
} }
@@ -258,6 +565,10 @@ export const logisticsRouter = router({
return detail return detail
}), }),
// ─────────────────────────────────────────────────────────────────────────
// Visa applications
// ─────────────────────────────────────────────────────────────────────────
/** /**
* List all VisaApplication rows for a program, joined with the project + * List all VisaApplication rows for a program, joined with the project +
* attendee + project so the admin Visas tab can render a flat table. * attendee + project so the admin Visas tab can render a flat table.

View File

@@ -66,8 +66,16 @@ async function buildConfirmedFinalist() {
}, },
}) })
// Hotel for the program (1:1) const attendee = await prisma.attendingMember.create({
await prisma.hotel.create({ data: {
confirmationId: confirmation.id,
userId: user.id,
needsVisa: true,
},
})
// Hotel for the program + HotelStay assigning the attendee to it
const hotel = await prisma.hotel.create({
data: { data: {
programId: program.id, programId: program.id,
name: 'Hotel Hermitage', name: 'Hotel Hermitage',
@@ -77,11 +85,13 @@ async function buildConfirmedFinalist() {
}, },
}) })
const attendee = await prisma.attendingMember.create({ await prisma.hotelStay.create({
data: { data: {
confirmationId: confirmation.id, attendingMemberId: attendee.id,
userId: user.id, hotelId: hotel.id,
needsVisa: true, roomNumber: '204',
checkInAt: new Date('2026-06-20T14:00:00Z'),
checkOutAt: new Date('2026-06-23T11:00:00Z'),
}, },
}) })
@@ -125,6 +135,9 @@ describe('applicant.getMyLogistics', () => {
await prisma.flightDetail.deleteMany({ await prisma.flightDetail.deleteMany({
where: { attendingMember: { confirmation: { project: { programId } } } }, where: { attendingMember: { confirmation: { project: { programId } } } },
}) })
await prisma.hotelStay.deleteMany({
where: { attendingMember: { confirmation: { project: { programId } } } },
})
await prisma.hotel.deleteMany({ where: { programId } }) await prisma.hotel.deleteMany({ where: { programId } })
await prisma.attendingMember.deleteMany({ await prisma.attendingMember.deleteMany({
where: { confirmation: { project: { programId } } }, where: { confirmation: { project: { programId } } },
@@ -154,6 +167,8 @@ describe('applicant.getMyLogistics', () => {
expect(result!.confirmationStatus).toBe('CONFIRMED') expect(result!.confirmationStatus).toBe('CONFIRMED')
expect(result!.hotel).not.toBeNull() expect(result!.hotel).not.toBeNull()
expect(result!.hotel!.name).toBe('Hotel Hermitage') expect(result!.hotel!.name).toBe('Hotel Hermitage')
expect(result!.room).not.toBeNull()
expect(result!.room!.roomNumber).toBe('204')
expect(result!.myFlight).not.toBeNull() expect(result!.myFlight).not.toBeNull()
expect(result!.myFlight!.arrivalFlightNumber).toBe('AF1234') expect(result!.myFlight!.arrivalFlightNumber).toBe('AF1234')
expect(result!.myFlight!.arrivalAirport).toBe('NCE') expect(result!.myFlight!.arrivalAirport).toBe('NCE')
@@ -227,6 +242,9 @@ describe('applicant.updateMyVisaNationality', () => {
await prisma.flightDetail.deleteMany({ await prisma.flightDetail.deleteMany({
where: { attendingMember: { confirmation: { project: { programId } } } }, where: { attendingMember: { confirmation: { project: { programId } } } },
}) })
await prisma.hotelStay.deleteMany({
where: { attendingMember: { confirmation: { project: { programId } } } },
})
await prisma.hotel.deleteMany({ where: { programId } }) await prisma.hotel.deleteMany({ where: { programId } })
await prisma.attendingMember.deleteMany({ await prisma.attendingMember.deleteMany({
where: { confirmation: { project: { programId } } }, where: { confirmation: { project: { programId } } },

View File

@@ -1,99 +0,0 @@
import { afterAll, describe, expect, it } from 'vitest'
import { prisma, createCaller } from '../setup'
import { createTestUser, createTestProgram, cleanupTestData, uid } from '../helpers'
import { logisticsRouter } from '../../src/server/routers/logistics'
describe('logistics.getHotel + upsertHotel', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
for (const programId of programIds) {
await prisma.hotel.deleteMany({ where: { programId } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
it('getHotel returns null when no hotel set', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `hotel-empty-${uid()}` })
programIds.push(program.id)
const caller = createCaller(logisticsRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const hotel = await caller.getHotel({ programId: program.id })
expect(hotel).toBeNull()
})
it('upsertHotel creates a hotel on first call', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `hotel-create-${uid()}` })
programIds.push(program.id)
const caller = createCaller(logisticsRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const hotel = await caller.upsertHotel({
programId: program.id,
name: 'Hotel Hermitage',
address: 'Square Beaumarchais, 98000 Monaco',
link: 'https://hotelhermitagemontecarlo.com',
notes: 'Adjacent to the venue',
})
expect(hotel.name).toBe('Hotel Hermitage')
expect(hotel.programId).toBe(program.id)
expect(hotel.link).toContain('hermitage')
})
it('upsertHotel updates the existing hotel on second call', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `hotel-update-${uid()}` })
programIds.push(program.id)
const caller = createCaller(logisticsRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await caller.upsertHotel({
programId: program.id,
name: 'Hotel A',
})
const updated = await caller.upsertHotel({
programId: program.id,
name: 'Hotel B',
notes: 'Changed our mind',
})
expect(updated.name).toBe('Hotel B')
expect(updated.notes).toBe('Changed our mind')
// Still 1:1 — no second hotel row
const count = await prisma.hotel.count({ where: { programId: program.id } })
expect(count).toBe(1)
})
it('upsertHotel normalizes empty link string to null', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `hotel-empty-link-${uid()}` })
programIds.push(program.id)
const caller = createCaller(logisticsRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const hotel = await caller.upsertHotel({
programId: program.id,
name: 'No-link Hotel',
link: '',
})
expect(hotel.link).toBeNull()
})
})

View File

@@ -0,0 +1,476 @@
/**
* Task 2: Multi-hotel + rooming assignment procedures
* Tests: listHotels, createHotel, updateHotel, deleteHotel, listRooming,
* assignStay, assignTeamToHotel, unassignStay
*/
import { afterAll, describe, expect, it } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser,
createTestProgram,
createTestProject,
cleanupTestData,
uid,
} from '../helpers'
import { logisticsRouter } from '../../src/server/routers/logistics'
// ---------------------------------------------------------------------------
// Helper: build a CONFIRMED FinalistConfirmation with N attendees
// ---------------------------------------------------------------------------
async function setupConfirmedTeam(
programId: string,
projectTitle: string,
memberCount: number,
) {
const project = await createTestProject(programId, {
title: projectTitle,
competitionCategory: 'STARTUP',
})
const users = await Promise.all(
Array.from({ length: memberCount }, (_, i) =>
prisma.user.create({
data: {
id: uid('user'),
email: `member${i}_${uid()}@test.local`,
name: `Member ${i} of ${projectTitle}`,
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'ACTIVE',
},
}),
),
)
const confirmation = await prisma.finalistConfirmation.create({
data: {
projectId: project.id,
category: 'STARTUP',
status: 'CONFIRMED',
deadline: new Date(Date.now() + 86400000),
token: `tok_${uid()}`,
confirmedAt: new Date(),
},
})
const attendees = await Promise.all(
users.map((u) =>
prisma.attendingMember.create({
data: { confirmationId: confirmation.id, userId: u.id, needsVisa: false },
}),
),
)
return { project, users, confirmation, attendees }
}
// ---------------------------------------------------------------------------
// Suite
// ---------------------------------------------------------------------------
describe('logistics hotel CRUD + rooming assignment', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
for (const programId of programIds) {
// Clean in dependency order
await prisma.hotelStay.deleteMany({
where: { attendingMember: { confirmation: { project: { programId } } } },
})
await prisma.attendingMember.deleteMany({
where: { confirmation: { project: { programId } } },
})
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } })
await prisma.hotel.deleteMany({ where: { programId } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
// ── listHotels ────────────────────────────────────────────────────────────
it('listHotels returns all hotels for a program with stay counts', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `hotels-list-${uid()}` })
programIds.push(program.id)
// Create 2 hotels directly
await prisma.hotel.createMany({
data: [
{ programId: program.id, name: 'Hotel Alpha', address: '1 Alpha St' },
{ programId: program.id, name: 'Hotel Beta' },
],
})
const caller = createCaller(logisticsRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const hotels = await caller.listHotels({ programId: program.id })
expect(hotels).toHaveLength(2)
// Sorted by name ascending
expect(hotels[0].name).toBe('Hotel Alpha')
expect(hotels[1].name).toBe('Hotel Beta')
// _count.stays present
expect(hotels[0]._count.stays).toBe(0)
})
// ── createHotel ───────────────────────────────────────────────────────────
it('createHotel creates a hotel and normalizes empty link to null', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `hotels-create-${uid()}` })
programIds.push(program.id)
const caller = createCaller(logisticsRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const hotel = await caller.createHotel({
programId: program.id,
name: 'Hotel Hermitage',
address: 'Square Beaumarchais, Monaco',
link: '',
notes: 'Near the venue',
})
expect(hotel.name).toBe('Hotel Hermitage')
expect(hotel.programId).toBe(program.id)
expect(hotel.link).toBeNull()
expect(hotel.notes).toBe('Near the venue')
// A second hotel can be created for the same program
const hotel2 = await caller.createHotel({
programId: program.id,
name: 'Hotel Fairmont',
link: 'https://fairmont.com',
})
expect(hotel2.name).toBe('Hotel Fairmont')
expect(hotel2.link).toBe('https://fairmont.com')
const count = await prisma.hotel.count({ where: { programId: program.id } })
expect(count).toBe(2)
})
// ── updateHotel ───────────────────────────────────────────────────────────
it('updateHotel changes the hotel fields', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `hotels-update-${uid()}` })
programIds.push(program.id)
const created = await prisma.hotel.create({
data: { programId: program.id, name: 'Old Name', address: 'Old Address' },
})
const caller = createCaller(logisticsRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const updated = await caller.updateHotel({
id: created.id,
name: 'New Name',
address: 'New Address',
notes: 'Updated notes',
})
expect(updated.id).toBe(created.id)
expect(updated.name).toBe('New Name')
expect(updated.notes).toBe('Updated notes')
})
// ── deleteHotel ───────────────────────────────────────────────────────────
it('deleteHotel rejects when hotel has occupants', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `hotels-del-occupied-${uid()}` })
programIds.push(program.id)
const hotel = await prisma.hotel.create({
data: { programId: program.id, name: 'Occupied Hotel' },
})
const { attendees } = await setupConfirmedTeam(program.id, `OccupiedTeam-${uid()}`, 1)
// Assign the stay
await prisma.hotelStay.create({
data: { attendingMemberId: attendees[0].id, hotelId: hotel.id },
})
const caller = createCaller(logisticsRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await expect(caller.deleteHotel({ id: hotel.id })).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: expect.stringContaining('Reassign'),
})
})
it('deleteHotel succeeds when hotel has no occupants', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `hotels-del-empty-${uid()}` })
programIds.push(program.id)
const hotel = await prisma.hotel.create({
data: { programId: program.id, name: 'Empty Hotel' },
})
const caller = createCaller(logisticsRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const result = await caller.deleteHotel({ id: hotel.id })
expect(result.success).toBe(true)
const found = await prisma.hotel.findUnique({ where: { id: hotel.id } })
expect(found).toBeNull()
})
// ── assignStay (upsert: create → update) ─────────────────────────────────
it('assignStay upserts: creates then updates room number', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `hotels-assign-${uid()}` })
programIds.push(program.id)
const hotel = await prisma.hotel.create({
data: { programId: program.id, name: 'Stay Hotel' },
})
const { attendees } = await setupConfirmedTeam(program.id, `StayTeam-${uid()}`, 1)
const caller = createCaller(logisticsRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
// Create
const stay1 = await caller.assignStay({
attendingMemberId: attendees[0].id,
hotelId: hotel.id,
roomNumber: '101',
})
expect(stay1.hotelId).toBe(hotel.id)
expect(stay1.roomNumber).toBe('101')
// Update (upsert same attendee → same row)
const stay2 = await caller.assignStay({
attendingMemberId: attendees[0].id,
hotelId: hotel.id,
roomNumber: '202',
})
expect(stay2.id).toBe(stay1.id)
expect(stay2.roomNumber).toBe('202')
// Exactly 1 row
const count = await prisma.hotelStay.count({
where: { attendingMemberId: attendees[0].id },
})
expect(count).toBe(1)
})
it('assignStay rejects when hotel program does not match attendee program', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program1 = await createTestProgram({ name: `hotels-mismatch-p1-${uid()}` })
const program2 = await createTestProgram({ name: `hotels-mismatch-p2-${uid()}` })
programIds.push(program1.id, program2.id)
const hotelInP2 = await prisma.hotel.create({
data: { programId: program2.id, name: 'Wrong Hotel' },
})
const { attendees } = await setupConfirmedTeam(program1.id, `MismatchTeam-${uid()}`, 1)
const caller = createCaller(logisticsRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await expect(
caller.assignStay({ attendingMemberId: attendees[0].id, hotelId: hotelInP2.id }),
).rejects.toMatchObject({ code: 'BAD_REQUEST' })
})
// ── assignTeamToHotel ─────────────────────────────────────────────────────
it('assignTeamToHotel assigns all attendees of a 2-person team', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `hotels-team-assign-${uid()}` })
programIds.push(program.id)
const hotel = await prisma.hotel.create({
data: { programId: program.id, name: 'Team Hotel' },
})
const { confirmation, attendees } = await setupConfirmedTeam(
program.id,
`TeamAssign-${uid()}`,
2,
)
const caller = createCaller(logisticsRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await caller.assignTeamToHotel({
confirmationId: confirmation.id,
hotelId: hotel.id,
checkInAt: new Date('2026-06-28T14:00:00Z'),
checkOutAt: new Date('2026-07-01T12:00:00Z'),
})
const stays = await prisma.hotelStay.findMany({
where: { attendingMember: { confirmationId: confirmation.id } },
})
expect(stays).toHaveLength(2)
for (const s of stays) {
expect(s.hotelId).toBe(hotel.id)
expect(s.checkInAt?.toISOString()).toBe('2026-06-28T14:00:00.000Z')
}
// roomNumber not set (null by default on create)
expect(stays[0].roomNumber).toBeNull()
// Call again (update) — preserve existing roomNumber (here null → still null)
await caller.assignTeamToHotel({
confirmationId: confirmation.id,
hotelId: hotel.id,
})
const stays2 = await prisma.hotelStay.findMany({
where: { attendingMember: { confirmationId: confirmation.id } },
})
expect(stays2).toHaveLength(2)
// Set individual room, then re-run assignTeamToHotel → roomNumber preserved
await caller.assignStay({
attendingMemberId: attendees[0].id,
hotelId: hotel.id,
roomNumber: '301',
})
await caller.assignTeamToHotel({ confirmationId: confirmation.id, hotelId: hotel.id })
const preserved = await prisma.hotelStay.findUnique({
where: { attendingMemberId: attendees[0].id },
})
expect(preserved?.roomNumber).toBe('301')
})
// ── unassignStay ──────────────────────────────────────────────────────────
it('unassignStay removes the stay (and is a no-op when none exists)', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `hotels-unassign-${uid()}` })
programIds.push(program.id)
const hotel = await prisma.hotel.create({
data: { programId: program.id, name: 'Unassign Hotel' },
})
const { attendees } = await setupConfirmedTeam(program.id, `UnassignTeam-${uid()}`, 1)
const caller = createCaller(logisticsRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await caller.assignStay({ attendingMemberId: attendees[0].id, hotelId: hotel.id })
await caller.unassignStay({ attendingMemberId: attendees[0].id })
const count = await prisma.hotelStay.count({
where: { attendingMemberId: attendees[0].id },
})
expect(count).toBe(0)
// No-op: calling again doesn't throw
await expect(
caller.unassignStay({ attendingMemberId: attendees[0].id }),
).resolves.not.toThrow()
})
// ── listRooming ───────────────────────────────────────────────────────────
it('listRooming returns CONFIRMED attendees with and without stays', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `hotels-rooming-${uid()}` })
programIds.push(program.id)
const hotel = await prisma.hotel.create({
data: { programId: program.id, name: 'Rooming Hotel' },
})
// One 2-person confirmed team
const { attendees, project } = await setupConfirmedTeam(
program.id,
`RoomingTeam-${uid()}`,
2,
)
// Assign only attendee[0] to a hotel
await prisma.hotelStay.create({
data: { attendingMemberId: attendees[0].id, hotelId: hotel.id, roomNumber: '42' },
})
// Also create a PENDING confirmation (should be excluded)
const pendingProject = await createTestProject(program.id, {
title: `Pending-${uid()}`,
competitionCategory: 'STARTUP',
})
const pendingUser = await prisma.user.create({
data: {
id: uid('user'),
email: `pending_${uid()}@test.local`,
name: 'Pending User',
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'ACTIVE',
},
})
userIds.push(pendingUser.id)
const pendingConf = await prisma.finalistConfirmation.create({
data: {
projectId: pendingProject.id,
category: 'STARTUP',
status: 'PENDING',
deadline: new Date(Date.now() + 86400000),
token: `tok_${uid()}`,
},
})
await prisma.attendingMember.create({
data: { confirmationId: pendingConf.id, userId: pendingUser.id },
})
const caller = createCaller(logisticsRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const rows = await caller.listRooming({ programId: program.id })
type RoomingRow = (typeof rows)[number]
// Only CONFIRMED (2 rows from the one confirmed team)
expect(rows).toHaveLength(2)
// All rows belong to the confirmed team's project
expect(rows.every((r: RoomingRow) => r.projectId === project.id)).toBe(true)
// Attendee with stay has non-null stay
const withStay = rows.find((r: RoomingRow) => r.attendingMemberId === attendees[0].id)
expect(withStay?.stay).not.toBeNull()
expect(withStay?.stay?.roomNumber).toBe('42')
// Attendee without stay has null stay
const withoutStay = rows.find((r: RoomingRow) => r.attendingMemberId === attendees[1].id)
expect(withoutStay?.stay).toBeNull()
})
})