From 884c96c71002a0063e8760fe85ca378f26abf919 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 4 Jun 2026 16:20:21 +0200 Subject: [PATCH] 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 --- src/server/routers/logistics.ts | 100 +++++++++++ tests/unit/logistics-comms.test.ts | 259 +++++++++++++++++++++++++++++ 2 files changed, 359 insertions(+) create mode 100644 tests/unit/logistics-comms.test.ts diff --git a/src/server/routers/logistics.ts b/src/server/routers/logistics.ts index 2361326..7ca7515 100644 --- a/src/server/routers/logistics.ts +++ b/src/server/routers/logistics.ts @@ -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([ + '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 = { + 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 }), diff --git a/tests/unit/logistics-comms.test.ts b/tests/unit/logistics-comms.test.ts new file mode 100644 index 0000000..88b86f3 --- /dev/null +++ b/tests/unit/logistics-comms.test.ts @@ -0,0 +1,259 @@ +/** + * Task 6: Travel + visa attendee emails + * Tests that setFlightStatus(CONFIRMED) and updateVisaApplication(status change) + * create the right InAppNotification rows for the attending member. + */ +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' + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +async function createApplicant(tag: string) { + const id = uid('user') + return prisma.user.create({ + data: { + id, + email: `${tag}_${id}@test.local`, + name: `Test User ${tag}`, + role: 'APPLICANT', + roles: ['APPLICANT'], + status: 'ACTIVE', + }, + }) +} + +/** + * Creates a confirmed attendee with a flight detail row in PENDING status. + * Returns all entities needed by the tests. + */ +async function setupFlightScenario(label: string) { + const program = await createTestProgram({ name: `comms-flight-${label}-${uid()}` }) + const user = await createApplicant(`flight_${label}`) + const project = await createTestProject(program.id, { + title: `Project ${label}`, + competitionCategory: 'STARTUP', + }) + await prisma.teamMember.create({ + data: { projectId: project.id, userId: user.id, role: 'LEAD' }, + }) + const confirmation = await prisma.finalistConfirmation.create({ + data: { + projectId: project.id, + category: 'STARTUP', + status: 'CONFIRMED', + deadline: new Date(Date.now() + 86_400_000), + token: `tok_${uid()}`, + confirmedAt: new Date(), + }, + }) + const attendingMember = await prisma.attendingMember.create({ + data: { confirmationId: confirmation.id, userId: user.id, needsVisa: false }, + }) + const flightDetail = await prisma.flightDetail.create({ + data: { + attendingMemberId: attendingMember.id, + arrivalAt: new Date('2026-06-28T12:00:00Z'), + arrivalFlightNumber: 'AF7400', + arrivalAirport: 'NCE', + departureAt: new Date('2026-07-01T16:00:00Z'), + departureFlightNumber: 'AF7401', + departureAirport: 'NCE', + status: 'PENDING', + }, + }) + return { program, user, project, confirmation, attendingMember, flightDetail } +} + +/** + * Creates a confirmed attendee with a visa application in REQUESTED status. + */ +async function setupVisaScenario(label: string) { + const program = await createTestProgram({ name: `comms-visa-${label}-${uid()}` }) + const user = await createApplicant(`visa_${label}`) + const project = await createTestProject(program.id, { + title: `Visa Project ${label}`, + competitionCategory: 'STARTUP', + }) + await prisma.teamMember.create({ + data: { projectId: project.id, userId: user.id, role: 'LEAD' }, + }) + const confirmation = await prisma.finalistConfirmation.create({ + data: { + projectId: project.id, + category: 'STARTUP', + status: 'CONFIRMED', + deadline: new Date(Date.now() + 86_400_000), + token: `tok_${uid()}`, + confirmedAt: new Date(), + }, + }) + const attendingMember = await prisma.attendingMember.create({ + data: { confirmationId: confirmation.id, userId: user.id, needsVisa: true }, + }) + const visaApplication = await prisma.visaApplication.create({ + data: { attendingMemberId: attendingMember.id, status: 'REQUESTED' }, + }) + return { program, user, project, confirmation, attendingMember, visaApplication } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('logistics comms: flight confirmed notification', () => { + const programIds: string[] = [] + const userIds: string[] = [] + + afterAll(async () => { + // Clean up InAppNotification rows first + for (const userId of userIds) { + await prisma.inAppNotification.deleteMany({ where: { userId } }) + } + for (const programId of programIds) { + await prisma.flightDetail.deleteMany({ + where: { attendingMember: { confirmation: { project: { programId } } } }, + }) + await prisma.attendingMember.deleteMany({ + where: { confirmation: { project: { programId } } }, + }) + await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } }) + await cleanupTestData(programId, []) + } + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } + }) + + it('creates TRAVEL_CONFIRMED notification when flight status set to CONFIRMED', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const { program, user, flightDetail } = await setupFlightScenario('confirm') + programIds.push(program.id) + userIds.push(user.id) + + const caller = createCaller(logisticsRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + await caller.setFlightStatus({ flightDetailId: flightDetail.id, status: 'CONFIRMED' }) + + const notification = await prisma.inAppNotification.findFirst({ + where: { userId: user.id, type: 'TRAVEL_CONFIRMED' }, + }) + expect(notification).not.toBeNull() + expect(notification!.type).toBe('TRAVEL_CONFIRMED') + }) + + it('does NOT create TRAVEL_CONFIRMED notification when status is set to PENDING', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const { program, user, flightDetail } = await setupFlightScenario('pending') + programIds.push(program.id) + userIds.push(user.id) + + // First set to CONFIRMED to have a baseline, then revert to PENDING + const caller = createCaller(logisticsRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + // Start with CONFIRMED so we know a notification would be created + await caller.setFlightStatus({ flightDetailId: flightDetail.id, status: 'CONFIRMED' }) + + // Clear notifications for this user so we can test the PENDING case cleanly + await prisma.inAppNotification.deleteMany({ where: { userId: user.id } }) + + // Now set back to PENDING — must NOT create a new TRAVEL_CONFIRMED notification + await caller.setFlightStatus({ flightDetailId: flightDetail.id, status: 'PENDING' }) + + const notification = await prisma.inAppNotification.findFirst({ + where: { userId: user.id, type: 'TRAVEL_CONFIRMED' }, + }) + expect(notification).toBeNull() + }) +}) + +describe('logistics comms: visa status update notification', () => { + const programIds: string[] = [] + const userIds: string[] = [] + + afterAll(async () => { + // Clean up InAppNotification rows first + for (const userId of userIds) { + await prisma.inAppNotification.deleteMany({ where: { userId } }) + } + for (const programId of programIds) { + await prisma.visaApplication.deleteMany({ + where: { attendingMember: { confirmation: { project: { programId } } } }, + }) + await prisma.attendingMember.deleteMany({ + where: { confirmation: { project: { programId } } }, + }) + await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } }) + await cleanupTestData(programId, []) + } + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } + }) + + it('creates VISA_STATUS_UPDATE notification when visa status updated to GRANTED', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const { program, user, visaApplication } = await setupVisaScenario('granted') + programIds.push(program.id) + userIds.push(user.id) + + const caller = createCaller(logisticsRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + await caller.updateVisaApplication({ id: visaApplication.id, status: 'GRANTED' }) + + const notification = await prisma.inAppNotification.findFirst({ + where: { userId: user.id, type: 'VISA_STATUS_UPDATE' }, + }) + expect(notification).not.toBeNull() + expect(notification!.type).toBe('VISA_STATUS_UPDATE') + }) + + it('does NOT create duplicate VISA_STATUS_UPDATE when status is unchanged', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const { program, user, visaApplication } = await setupVisaScenario('no-dup') + programIds.push(program.id) + userIds.push(user.id) + + const caller = createCaller(logisticsRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + // First update: REQUESTED → GRANTED (creates a notification) + await caller.updateVisaApplication({ id: visaApplication.id, status: 'GRANTED' }) + + // Second update: GRANTED → GRANTED again (same status, should NOT create another) + await caller.updateVisaApplication({ id: visaApplication.id, status: 'GRANTED' }) + + const notifications = await prisma.inAppNotification.findMany({ + where: { userId: user.id, type: 'VISA_STATUS_UPDATE' }, + }) + expect(notifications).toHaveLength(1) + }) +})