diff --git a/src/lib/email.ts b/src/lib/email.ts
index 343ded6..70ea1fb 100644
--- a/src/lib/email.ts
+++ b/src/lib/email.ts
@@ -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(
``
: ''
+ 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
+ ? `
` +
+ roomLines.map((l) => `- ${escapeHtml(l)}
`).join('') +
+ `
`
+ : ''
+
const hotelHtml = hotel
? `Hotel
` +
infoBox(
`${escapeHtml(hotel.name)}` +
(hotel.address ? `
${escapeHtml(hotel.address)}` : '') +
- (hotel.link ? `
View hotel` : ''),
+ (hotel.link ? `
View hotel` : '') +
+ 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 = {
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(
diff --git a/src/server/routers/logistics.ts b/src/server/routers/logistics.ts
index fd37f6f..18a9d73 100644
--- a/src/server/routers/logistics.ts
+++ b/src/server/routers/logistics.ts
@@ -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 = {
+ 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.
diff --git a/tests/unit/logistics-hotels.test.ts b/tests/unit/logistics-hotels.test.ts
new file mode 100644
index 0000000..ff5dd48
--- /dev/null
+++ b/tests/unit/logistics-hotels.test.ts
@@ -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()
+ })
+})