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>
477 lines
16 KiB
TypeScript
477 lines
16 KiB
TypeScript
/**
|
|
* 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()
|
|
})
|
|
})
|