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:
@@ -6,15 +6,23 @@ import { logAudit } from '../utils/audit'
|
||||
import { createNotification, NotificationTypes } from '../services/in-app-notification'
|
||||
|
||||
export const logisticsRouter = router({
|
||||
/** Read the hotel for a program (1:1). Null if not yet set. */
|
||||
getHotel: adminProcedure
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Hotels CRUD (multi-hotel per edition)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** List all hotels for an edition, including occupancy count. */
|
||||
listHotels: adminProcedure
|
||||
.input(z.object({ programId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.hotel.findUnique({ where: { programId: input.programId } })
|
||||
return ctx.prisma.hotel.findMany({
|
||||
where: { programId: input.programId },
|
||||
include: { _count: { select: { stays: true } } },
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
}),
|
||||
|
||||
/** Create or update the program's hotel. Empty link strings are stored as null. */
|
||||
upsertHotel: adminProcedure
|
||||
/** Create a new hotel for the edition. Empty link strings are stored as null. */
|
||||
createHotel: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string(),
|
||||
@@ -30,26 +38,19 @@ export const logisticsRouter = router({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const link = input.link && input.link.trim().length > 0 ? input.link : null
|
||||
const hotel = await ctx.prisma.hotel.upsert({
|
||||
where: { programId: input.programId },
|
||||
create: {
|
||||
const hotel = await ctx.prisma.hotel.create({
|
||||
data: {
|
||||
programId: input.programId,
|
||||
name: input.name,
|
||||
address: input.address ?? null,
|
||||
link,
|
||||
notes: input.notes ?? null,
|
||||
},
|
||||
update: {
|
||||
name: input.name,
|
||||
address: input.address ?? null,
|
||||
link,
|
||||
notes: input.notes ?? null,
|
||||
},
|
||||
})
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'HOTEL_UPSERT',
|
||||
action: 'HOTEL_CREATE',
|
||||
entityType: 'Hotel',
|
||||
entityId: hotel.id,
|
||||
detailsJson: { programId: input.programId, name: input.name },
|
||||
@@ -57,6 +58,301 @@ export const logisticsRouter = router({
|
||||
return hotel
|
||||
}),
|
||||
|
||||
/** Update an existing hotel's fields. Empty link strings are stored as null. */
|
||||
updateHotel: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(200),
|
||||
address: z.string().max(500).optional().nullable(),
|
||||
link: z
|
||||
.string()
|
||||
.url()
|
||||
.or(z.literal(''))
|
||||
.optional()
|
||||
.nullable(),
|
||||
notes: z.string().max(2000).optional().nullable(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const link =
|
||||
input.link !== undefined
|
||||
? input.link && input.link.trim().length > 0
|
||||
? input.link
|
||||
: null
|
||||
: undefined
|
||||
const hotel = await ctx.prisma.hotel.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
name: input.name,
|
||||
...(input.address !== undefined ? { address: input.address } : {}),
|
||||
...(link !== undefined ? { link } : {}),
|
||||
...(input.notes !== undefined ? { notes: input.notes } : {}),
|
||||
},
|
||||
})
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'HOTEL_UPDATE',
|
||||
entityType: 'Hotel',
|
||||
entityId: hotel.id,
|
||||
detailsJson: { id: input.id, name: input.name },
|
||||
})
|
||||
return hotel
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a hotel. Rejected with BAD_REQUEST if any HotelStay rows reference it
|
||||
* (the user must reassign occupants first).
|
||||
*/
|
||||
deleteHotel: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const occupantCount = await ctx.prisma.hotelStay.count({
|
||||
where: { hotelId: input.id },
|
||||
})
|
||||
if (occupantCount > 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Reassign ${occupantCount} occupant(s) before deleting this hotel.`,
|
||||
})
|
||||
}
|
||||
const hotel = await ctx.prisma.hotel.delete({ where: { id: input.id } })
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'HOTEL_DELETE',
|
||||
entityType: 'Hotel',
|
||||
entityId: hotel.id,
|
||||
detailsJson: { id: input.id, name: hotel.name },
|
||||
})
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Rooming — per-attendee hotel/room assignment
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One row per CONFIRMED AttendingMember in the program.
|
||||
* Each row includes their hotelStay (null when not yet assigned).
|
||||
* Sorted by project title then user name.
|
||||
*/
|
||||
listRooming: adminProcedure
|
||||
.input(z.object({ programId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const rows = await ctx.prisma.attendingMember.findMany({
|
||||
where: {
|
||||
confirmation: {
|
||||
status: 'CONFIRMED',
|
||||
project: { programId: input.programId },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
confirmation: {
|
||||
select: {
|
||||
id: true,
|
||||
project: { select: { id: true, title: true } },
|
||||
},
|
||||
},
|
||||
hotelStay: {
|
||||
select: { hotelId: true, roomNumber: true, checkInAt: true, checkOutAt: true },
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ confirmation: { project: { title: 'asc' } } },
|
||||
{ user: { name: 'asc' } },
|
||||
],
|
||||
})
|
||||
|
||||
return rows.map((r) => ({
|
||||
attendingMemberId: r.id,
|
||||
confirmationId: r.confirmation.id,
|
||||
projectId: r.confirmation.project.id,
|
||||
projectTitle: r.confirmation.project.title,
|
||||
user: r.user,
|
||||
stay: r.hotelStay ?? null,
|
||||
}))
|
||||
}),
|
||||
|
||||
/**
|
||||
* Upsert an individual attendee's hotel/room assignment.
|
||||
* Validates that the hotel belongs to the same program as the attendee.
|
||||
*/
|
||||
assignStay: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
attendingMemberId: z.string(),
|
||||
hotelId: z.string(),
|
||||
roomNumber: z.string().max(50).optional().nullable(),
|
||||
checkInAt: z.date().optional().nullable(),
|
||||
checkOutAt: z.date().optional().nullable(),
|
||||
notes: z.string().max(2000).optional().nullable(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Validate hotel belongs to the same program as the attendee
|
||||
const [attendee, hotel] = await Promise.all([
|
||||
ctx.prisma.attendingMember.findUnique({
|
||||
where: { id: input.attendingMemberId },
|
||||
select: {
|
||||
confirmation: { select: { project: { select: { programId: true } } } },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.hotel.findUnique({
|
||||
where: { id: input.hotelId },
|
||||
select: { programId: true },
|
||||
}),
|
||||
])
|
||||
|
||||
if (!attendee || !hotel) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Attendee or hotel not found.' })
|
||||
}
|
||||
if (attendee.confirmation.project.programId !== hotel.programId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Hotel does not belong to the same program as the attendee.',
|
||||
})
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {
|
||||
hotelId: input.hotelId,
|
||||
}
|
||||
if (input.roomNumber !== undefined) data.roomNumber = input.roomNumber
|
||||
if (input.checkInAt !== undefined) data.checkInAt = input.checkInAt
|
||||
if (input.checkOutAt !== undefined) data.checkOutAt = input.checkOutAt
|
||||
if (input.notes !== undefined) data.notes = input.notes
|
||||
|
||||
const stay = await ctx.prisma.hotelStay.upsert({
|
||||
where: { attendingMemberId: input.attendingMemberId },
|
||||
create: {
|
||||
attendingMemberId: input.attendingMemberId,
|
||||
hotelId: input.hotelId,
|
||||
roomNumber: input.roomNumber ?? null,
|
||||
checkInAt: input.checkInAt ?? null,
|
||||
checkOutAt: input.checkOutAt ?? null,
|
||||
notes: input.notes ?? null,
|
||||
},
|
||||
update: data,
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'HOTEL_STAY_ASSIGN',
|
||||
entityType: 'HotelStay',
|
||||
entityId: stay.id,
|
||||
detailsJson: { attendingMemberId: input.attendingMemberId, hotelId: input.hotelId },
|
||||
})
|
||||
return stay
|
||||
}),
|
||||
|
||||
/**
|
||||
* Assign all AttendingMembers of a confirmation to a hotel (the "whole team" shortcut).
|
||||
* Preserves each member's existing roomNumber on update; sets it to null on create.
|
||||
* Validates the hotel belongs to the same program as the confirmation's project.
|
||||
*/
|
||||
assignTeamToHotel: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
confirmationId: z.string(),
|
||||
hotelId: z.string(),
|
||||
checkInAt: z.date().optional().nullable(),
|
||||
checkOutAt: z.date().optional().nullable(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Validate program match
|
||||
const [confirmation, hotel] = await Promise.all([
|
||||
ctx.prisma.finalistConfirmation.findUnique({
|
||||
where: { id: input.confirmationId },
|
||||
select: {
|
||||
project: { select: { programId: true } },
|
||||
attendingMembers: { select: { id: true } },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.hotel.findUnique({
|
||||
where: { id: input.hotelId },
|
||||
select: { programId: true },
|
||||
}),
|
||||
])
|
||||
|
||||
if (!confirmation || !hotel) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Confirmation or hotel not found.' })
|
||||
}
|
||||
if (confirmation.project.programId !== hotel.programId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Hotel does not belong to the same program as this confirmation.',
|
||||
})
|
||||
}
|
||||
|
||||
// Upsert each attendee's stay, preserving existing roomNumber
|
||||
const results = await Promise.all(
|
||||
confirmation.attendingMembers.map(async (member) => {
|
||||
const existing = await ctx.prisma.hotelStay.findUnique({
|
||||
where: { attendingMemberId: member.id },
|
||||
select: { roomNumber: true },
|
||||
})
|
||||
return ctx.prisma.hotelStay.upsert({
|
||||
where: { attendingMemberId: member.id },
|
||||
create: {
|
||||
attendingMemberId: member.id,
|
||||
hotelId: input.hotelId,
|
||||
roomNumber: null,
|
||||
checkInAt: input.checkInAt ?? null,
|
||||
checkOutAt: input.checkOutAt ?? null,
|
||||
},
|
||||
update: {
|
||||
hotelId: input.hotelId,
|
||||
...(input.checkInAt !== undefined ? { checkInAt: input.checkInAt } : {}),
|
||||
...(input.checkOutAt !== undefined ? { checkOutAt: input.checkOutAt } : {}),
|
||||
// Preserve existing roomNumber (do not overwrite it)
|
||||
...(existing?.roomNumber !== undefined ? {} : {}),
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'HOTEL_TEAM_ASSIGN',
|
||||
entityType: 'FinalistConfirmation',
|
||||
entityId: input.confirmationId,
|
||||
detailsJson: {
|
||||
confirmationId: input.confirmationId,
|
||||
hotelId: input.hotelId,
|
||||
count: results.length,
|
||||
},
|
||||
})
|
||||
return { count: results.length }
|
||||
}),
|
||||
|
||||
/** Remove an attendee's hotel/room assignment. No-op safe (deleteMany). */
|
||||
unassignStay: adminProcedure
|
||||
.input(z.object({ attendingMemberId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.hotelStay.deleteMany({
|
||||
where: { attendingMemberId: input.attendingMemberId },
|
||||
})
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'HOTEL_STAY_UNASSIGN',
|
||||
entityType: 'HotelStay',
|
||||
entityId: input.attendingMemberId,
|
||||
detailsJson: { attendingMemberId: input.attendingMemberId },
|
||||
})
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Confirmations
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read-only listing of every FinalistConfirmation in a program, with the
|
||||
* joined project + attendee count + decline reason. Sorted by status
|
||||
@@ -104,6 +400,10 @@ export const logisticsRouter = router({
|
||||
})
|
||||
}),
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Flight details
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List all attending members for CONFIRMED finalists in a program, with
|
||||
* their (optional) flight details. One row per attendee — even those
|
||||
@@ -213,12 +513,14 @@ export const logisticsRouter = router({
|
||||
where: { id: detail.attendingMemberId },
|
||||
select: {
|
||||
userId: true,
|
||||
hotelStay: {
|
||||
include: { hotel: { select: { name: true, address: true, link: true } } },
|
||||
},
|
||||
confirmation: {
|
||||
select: {
|
||||
project: {
|
||||
select: {
|
||||
title: true,
|
||||
programId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -227,11 +529,7 @@ export const logisticsRouter = router({
|
||||
})
|
||||
if (attendee) {
|
||||
const projectTitle = attendee.confirmation.project.title
|
||||
const programId = attendee.confirmation.project.programId
|
||||
const hotel = await ctx.prisma.hotel.findUnique({
|
||||
where: { programId },
|
||||
select: { name: true, address: true, link: true },
|
||||
})
|
||||
const hotelStay = attendee.hotelStay
|
||||
await createNotification({
|
||||
userId: attendee.userId,
|
||||
type: NotificationTypes.TRAVEL_CONFIRMED,
|
||||
@@ -246,7 +544,16 @@ export const logisticsRouter = router({
|
||||
departureAt: detail.departureAt?.toISOString() ?? null,
|
||||
departureFlightNumber: detail.departureFlightNumber ?? null,
|
||||
departureAirport: detail.departureAirport ?? null,
|
||||
hotel: hotel ?? undefined,
|
||||
hotel: hotelStay?.hotel
|
||||
? {
|
||||
name: hotelStay.hotel.name,
|
||||
address: hotelStay.hotel.address ?? null,
|
||||
link: hotelStay.hotel.link ?? null,
|
||||
}
|
||||
: null,
|
||||
roomNumber: hotelStay?.roomNumber ?? null,
|
||||
checkInAt: hotelStay?.checkInAt?.toISOString() ?? null,
|
||||
checkOutAt: hotelStay?.checkOutAt?.toISOString() ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -258,6 +565,10 @@ export const logisticsRouter = router({
|
||||
return detail
|
||||
}),
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Visa applications
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List all VisaApplication rows for a program, joined with the project +
|
||||
* attendee + project so the admin Visas tab can render a flat table.
|
||||
|
||||
Reference in New Issue
Block a user