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

@@ -2297,6 +2297,11 @@ function getTravelConfirmedTemplate(
address?: string | null
link?: string | null
},
room?: {
roomNumber?: string | null
checkInAt?: string | null
checkOutAt?: string | null
},
): EmailTemplate {
const greeting = name ? `Hi ${name},` : 'Hi there,'
@@ -2333,18 +2338,38 @@ function getTravelConfirmedTemplate(
`</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
? `<h3 style="margin:20px 0 8px;color:#0f172a;font-size:14px;font-weight:600;text-transform:uppercase;letter-spacing:1px;">Hotel</h3>` +
infoBox(
`<strong>${escapeHtml(hotel.name)}</strong>` +
(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',
)
: ''
const roomTextLines = roomLines.length > 0 ? '\n' + roomLines.map((l) => ` ${l}`).join('\n') : ''
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 = `
@@ -2679,6 +2704,13 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
departureAirport: ctx.metadata?.departureAirport as 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) =>
getVisaStatusTemplate(

View File

@@ -6,15 +6,23 @@ import { logAudit } from '../utils/audit'
import { createNotification, NotificationTypes } from '../services/in-app-notification'
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() }))
.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. */
upsertHotel: adminProcedure
/** Create a new hotel for the edition. Empty link strings are stored as null. */
createHotel: adminProcedure
.input(
z.object({
programId: z.string(),
@@ -30,26 +38,19 @@ export const logisticsRouter = router({
)
.mutation(async ({ ctx, input }) => {
const link = input.link && input.link.trim().length > 0 ? input.link : null
const hotel = await ctx.prisma.hotel.upsert({
where: { programId: input.programId },
create: {
const hotel = await ctx.prisma.hotel.create({
data: {
programId: input.programId,
name: input.name,
address: input.address ?? null,
link,
notes: input.notes ?? null,
},
update: {
name: input.name,
address: input.address ?? null,
link,
notes: input.notes ?? null,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'HOTEL_UPSERT',
action: 'HOTEL_CREATE',
entityType: 'Hotel',
entityId: hotel.id,
detailsJson: { programId: input.programId, name: input.name },
@@ -57,6 +58,301 @@ export const logisticsRouter = router({
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
* 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
* their (optional) flight details. One row per attendee — even those
@@ -213,12 +513,14 @@ export const logisticsRouter = router({
where: { id: detail.attendingMemberId },
select: {
userId: true,
hotelStay: {
include: { hotel: { select: { name: true, address: true, link: true } } },
},
confirmation: {
select: {
project: {
select: {
title: true,
programId: true,
},
},
},
@@ -227,11 +529,7 @@ export const logisticsRouter = router({
})
if (attendee) {
const projectTitle = attendee.confirmation.project.title
const programId = attendee.confirmation.project.programId
const hotel = await ctx.prisma.hotel.findUnique({
where: { programId },
select: { name: true, address: true, link: true },
})
const hotelStay = attendee.hotelStay
await createNotification({
userId: attendee.userId,
type: NotificationTypes.TRAVEL_CONFIRMED,
@@ -246,7 +544,16 @@ export const logisticsRouter = router({
departureAt: detail.departureAt?.toISOString() ?? null,
departureFlightNumber: detail.departureFlightNumber ?? 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
}),
// ─────────────────────────────────────────────────────────────────────────
// Visa applications
// ─────────────────────────────────────────────────────────────────────────
/**
* List all VisaApplication rows for a program, joined with the project +
* attendee + project so the admin Visas tab can render a flat table.

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()
})
})