Compare commits
7 Commits
97951deb68
...
89ab5cc8e6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89ab5cc8e6 | ||
|
|
3bbc80332c | ||
|
|
9313eb96f0 | ||
|
|
4cd2651f9c | ||
|
|
75e63eb47f | ||
|
|
200b5e0cb9 | ||
|
|
42e6b5f8c0 |
76
docs/superpowers/plans/2026-06-04-multi-hotel-rooming.md
Normal file
76
docs/superpowers/plans/2026-06-04-multi-hotel-rooming.md
Normal 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).
|
||||||
108
docs/superpowers/specs/2026-06-04-multi-hotel-rooming-design.md
Normal file
108
docs/superpowers/specs/2026-06-04-multi-hotel-rooming-design.md
Normal 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.
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 } } },
|
||||||
|
|||||||
@@ -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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
476
tests/unit/logistics-hotels.test.ts
Normal file
476
tests/unit/logistics-hotels.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user