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:
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