feat(logistics): hotels CRUD + rooming + assignment procedures + travel email

Replace getHotel/upsertHotel with listHotels/createHotel/updateHotel/deleteHotel
(multi-hotel per edition). Add listRooming, assignStay, assignTeamToHotel, and
unassignStay procedures for per-attendee room assignments. Update setFlightStatus
to include attendee's HotelStay in TRAVEL_CONFIRMED notification metadata.
Extend getTravelConfirmedTemplate to render room number and check-in/out dates.
All procedures are adminProcedure and audit-logged. 10 new unit tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-04 19:19:18 +02:00
parent 75e63eb47f
commit 4cd2651f9c
3 changed files with 843 additions and 24 deletions

View File

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