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:
@@ -2297,6 +2297,11 @@ function getTravelConfirmedTemplate(
|
|||||||
address?: string | null
|
address?: string | null
|
||||||
link?: string | null
|
link?: string | null
|
||||||
},
|
},
|
||||||
|
room?: {
|
||||||
|
roomNumber?: string | null
|
||||||
|
checkInAt?: string | null
|
||||||
|
checkOutAt?: string | null
|
||||||
|
},
|
||||||
): EmailTemplate {
|
): EmailTemplate {
|
||||||
const greeting = name ? `Hi ${name},` : 'Hi there,'
|
const greeting = name ? `Hi ${name},` : 'Hi there,'
|
||||||
|
|
||||||
@@ -2333,18 +2338,38 @@ function getTravelConfirmedTemplate(
|
|||||||
`</ul>`
|
`</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
|
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>` +
|
? `<h3 style="margin:20px 0 8px;color:#0f172a;font-size:14px;font-weight:600;text-transform:uppercase;letter-spacing:1px;">Hotel</h3>` +
|
||||||
infoBox(
|
infoBox(
|
||||||
`<strong>${escapeHtml(hotel.name)}</strong>` +
|
`<strong>${escapeHtml(hotel.name)}</strong>` +
|
||||||
(hotel.address ? `<br>${escapeHtml(hotel.address)}` : '') +
|
(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',
|
'info',
|
||||||
)
|
)
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
|
const roomTextLines = roomLines.length > 0 ? '\n' + roomLines.map((l) => ` ${l}`).join('\n') : ''
|
||||||
const hotelText = hotel
|
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 = `
|
const content = `
|
||||||
@@ -2679,6 +2704,13 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
|
|||||||
departureAirport: ctx.metadata?.departureAirport as string | undefined,
|
departureAirport: ctx.metadata?.departureAirport as string | undefined,
|
||||||
},
|
},
|
||||||
ctx.metadata?.hotel as { name: string; address?: string; link?: 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) =>
|
VISA_STATUS_UPDATE: (ctx) =>
|
||||||
getVisaStatusTemplate(
|
getVisaStatusTemplate(
|
||||||
|
|||||||
@@ -6,15 +6,23 @@ import { logAudit } from '../utils/audit'
|
|||||||
import { createNotification, NotificationTypes } from '../services/in-app-notification'
|
import { createNotification, NotificationTypes } from '../services/in-app-notification'
|
||||||
|
|
||||||
export const logisticsRouter = router({
|
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() }))
|
.input(z.object({ programId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.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. */
|
/** Create a new hotel for the edition. Empty link strings are stored as null. */
|
||||||
upsertHotel: adminProcedure
|
createHotel: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
programId: z.string(),
|
programId: z.string(),
|
||||||
@@ -30,26 +38,19 @@ export const logisticsRouter = router({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const link = input.link && input.link.trim().length > 0 ? input.link : null
|
const link = input.link && input.link.trim().length > 0 ? input.link : null
|
||||||
const hotel = await ctx.prisma.hotel.upsert({
|
const hotel = await ctx.prisma.hotel.create({
|
||||||
where: { programId: input.programId },
|
data: {
|
||||||
create: {
|
|
||||||
programId: input.programId,
|
programId: input.programId,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
address: input.address ?? null,
|
address: input.address ?? null,
|
||||||
link,
|
link,
|
||||||
notes: input.notes ?? null,
|
notes: input.notes ?? null,
|
||||||
},
|
},
|
||||||
update: {
|
|
||||||
name: input.name,
|
|
||||||
address: input.address ?? null,
|
|
||||||
link,
|
|
||||||
notes: input.notes ?? null,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
await logAudit({
|
await logAudit({
|
||||||
prisma: ctx.prisma,
|
prisma: ctx.prisma,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
action: 'HOTEL_UPSERT',
|
action: 'HOTEL_CREATE',
|
||||||
entityType: 'Hotel',
|
entityType: 'Hotel',
|
||||||
entityId: hotel.id,
|
entityId: hotel.id,
|
||||||
detailsJson: { programId: input.programId, name: input.name },
|
detailsJson: { programId: input.programId, name: input.name },
|
||||||
@@ -57,6 +58,301 @@ export const logisticsRouter = router({
|
|||||||
return hotel
|
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
|
* Read-only listing of every FinalistConfirmation in a program, with the
|
||||||
* joined project + attendee count + decline reason. Sorted by status
|
* 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
|
* List all attending members for CONFIRMED finalists in a program, with
|
||||||
* their (optional) flight details. One row per attendee — even those
|
* their (optional) flight details. One row per attendee — even those
|
||||||
@@ -213,12 +513,14 @@ export const logisticsRouter = router({
|
|||||||
where: { id: detail.attendingMemberId },
|
where: { id: detail.attendingMemberId },
|
||||||
select: {
|
select: {
|
||||||
userId: true,
|
userId: true,
|
||||||
|
hotelStay: {
|
||||||
|
include: { hotel: { select: { name: true, address: true, link: true } } },
|
||||||
|
},
|
||||||
confirmation: {
|
confirmation: {
|
||||||
select: {
|
select: {
|
||||||
project: {
|
project: {
|
||||||
select: {
|
select: {
|
||||||
title: true,
|
title: true,
|
||||||
programId: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -227,11 +529,7 @@ export const logisticsRouter = router({
|
|||||||
})
|
})
|
||||||
if (attendee) {
|
if (attendee) {
|
||||||
const projectTitle = attendee.confirmation.project.title
|
const projectTitle = attendee.confirmation.project.title
|
||||||
const programId = attendee.confirmation.project.programId
|
const hotelStay = attendee.hotelStay
|
||||||
const hotel = await ctx.prisma.hotel.findUnique({
|
|
||||||
where: { programId },
|
|
||||||
select: { name: true, address: true, link: true },
|
|
||||||
})
|
|
||||||
await createNotification({
|
await createNotification({
|
||||||
userId: attendee.userId,
|
userId: attendee.userId,
|
||||||
type: NotificationTypes.TRAVEL_CONFIRMED,
|
type: NotificationTypes.TRAVEL_CONFIRMED,
|
||||||
@@ -246,7 +544,16 @@ export const logisticsRouter = router({
|
|||||||
departureAt: detail.departureAt?.toISOString() ?? null,
|
departureAt: detail.departureAt?.toISOString() ?? null,
|
||||||
departureFlightNumber: detail.departureFlightNumber ?? null,
|
departureFlightNumber: detail.departureFlightNumber ?? null,
|
||||||
departureAirport: detail.departureAirport ?? 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
|
return detail
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Visa applications
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all VisaApplication rows for a program, joined with the project +
|
* List all VisaApplication rows for a program, joined with the project +
|
||||||
* attendee + project so the admin Visas tab can render a flat table.
|
* attendee + project so the admin Visas tab can render a flat table.
|
||||||
|
|||||||
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