feat(logistics): travel-confirmed + visa-status emails to attendees

When a flight is set to CONFIRMED, fire a TRAVEL_CONFIRMED in-app
notification (+ email via the existing NotificationEmailSetting pipeline)
to the attending member. When a visa status changes to one of
INVITATION_SENT|APPOINTMENT_BOOKED|GRANTED|DENIED, fire a
VISA_STATUS_UPDATE notification. Both are best-effort (try/catch, never
throw inside the mutation). Admin notes are not forwarded to metadata.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-04 16:20:21 +02:00
parent 1b4ab6be18
commit 884c96c710
2 changed files with 359 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ import { FlightDetailStatus, VisaStatus } from '@prisma/client'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure } from '../trpc'
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. */
@@ -198,6 +199,56 @@ export const logisticsRouter = router({
entityId: detail.id,
detailsJson: { status: input.status },
})
// Send travel-confirmed notification to the attendee (best-effort — never throws)
if (input.status === 'CONFIRMED') {
try {
const attendee = await ctx.prisma.attendingMember.findUnique({
where: { id: detail.attendingMemberId },
select: {
userId: true,
confirmation: {
select: {
project: {
select: {
title: true,
programId: true,
},
},
},
},
},
})
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 },
})
await createNotification({
userId: attendee.userId,
type: NotificationTypes.TRAVEL_CONFIRMED,
title: 'Your grand-finale travel is confirmed',
message: 'Your flight details for the grand finale are confirmed.',
linkUrl: '/applicant',
metadata: {
projectTitle,
arrivalAt: detail.arrivalAt?.toISOString() ?? null,
arrivalFlightNumber: detail.arrivalFlightNumber ?? null,
arrivalAirport: detail.arrivalAirport ?? null,
departureAt: detail.departureAt?.toISOString() ?? null,
departureFlightNumber: detail.departureFlightNumber ?? null,
departureAirport: detail.departureAirport ?? null,
hotel: hotel ?? undefined,
},
})
}
} catch (err) {
console.error('[setFlightStatus] Failed to send TRAVEL_CONFIRMED notification:', err)
}
}
return detail
}),
@@ -281,6 +332,18 @@ export const logisticsRouter = router({
.mutation(async ({ ctx, input }) => {
const existing = await ctx.prisma.visaApplication.findUnique({
where: { id: input.id },
include: {
attendingMember: {
select: {
userId: true,
confirmation: {
select: {
project: { select: { title: true } },
},
},
},
},
},
})
if (!existing) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Visa application not found' })
@@ -313,6 +376,43 @@ export const logisticsRouter = router({
next: data,
},
})
// Send visa-status notification to the attendee when a notifiable status changes (best-effort)
const NOTIFIABLE_STATUSES = new Set<VisaStatus>([
'INVITATION_SENT',
'APPOINTMENT_BOOKED',
'GRANTED',
'DENIED',
])
if (
input.status !== undefined &&
input.status !== existing.status &&
NOTIFIABLE_STATUSES.has(input.status)
) {
try {
const projectTitle = existing.attendingMember.confirmation.project.title
const statusMessages: Record<string, string> = {
INVITATION_SENT: 'Your visa invitation letter for the Grand Finale has been sent.',
APPOINTMENT_BOOKED: 'A visa appointment has been booked for you.',
GRANTED: 'Congratulations — your visa for the Grand Finale has been granted!',
DENIED: 'Your visa application outcome is now available. Please contact the MOPC team.',
}
await createNotification({
userId: existing.attendingMember.userId,
type: NotificationTypes.VISA_STATUS_UPDATE,
title: 'Visa update for the grand finale',
message: statusMessages[input.status] ?? 'Your visa status has been updated.',
linkUrl: '/applicant',
metadata: {
projectTitle,
status: input.status,
},
})
} catch (err) {
console.error('[updateVisaApplication] Failed to send VISA_STATUS_UPDATE notification:', err)
}
}
return updated
}),