From 4cd2651f9cfcee65b8f06bf717a441adf013e892 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 4 Jun 2026 19:19:18 +0200 Subject: [PATCH] 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 --- src/lib/email.ts | 36 ++- src/server/routers/logistics.ts | 355 +++++++++++++++++++-- tests/unit/logistics-hotels.test.ts | 476 ++++++++++++++++++++++++++++ 3 files changed, 843 insertions(+), 24 deletions(-) create mode 100644 tests/unit/logistics-hotels.test.ts 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 + ? `` + : '' + 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() + }) +})