diff --git a/src/server/routers/finalist.ts b/src/server/routers/finalist.ts index f123ea6..598589f 100644 --- a/src/server/routers/finalist.ts +++ b/src/server/routers/finalist.ts @@ -9,6 +9,7 @@ import { } from '../services/finalist-confirmation' import { createNotification, + notifyAdmins, NotificationTypes, } from '../services/in-app-notification' import { sendFinalistConfirmationEmail } from '@/lib/email' @@ -298,6 +299,7 @@ export const finalistRouter = router({ project: { select: { id: true, + title: true, programId: true, program: { select: { defaultAttendeeCap: true } }, teamMembers: { select: { userId: true } }, @@ -374,6 +376,22 @@ export const finalistRouter = router({ attendingUserIds: input.attendingUserIds, }, }) + // Admin alert — best-effort, never throws + try { + await notifyAdmins({ + type: NotificationTypes.FINALIST_CONFIRMED, + title: 'Finalist confirmed', + message: `"${confirmation.project.title}" (${confirmation.category}) has confirmed grand-finale attendance.`, + linkUrl: '/admin/logistics', + metadata: { + projectId: confirmation.projectId, + projectTitle: confirmation.project.title, + category: confirmation.category, + }, + }) + } catch (err) { + console.error('[finalist.confirm] failed to send admin notification:', err) + } return { ok: true } }), @@ -390,7 +408,7 @@ export const finalistRouter = router({ const payload = verifyFinalistToken(input.token) const confirmation = await ctx.prisma.finalistConfirmation.findUnique({ where: { id: payload.confirmationId }, - include: { project: { select: { programId: true } } }, + include: { project: { select: { programId: true, title: true } } }, }) if (!confirmation) throw new TRPCError({ code: 'NOT_FOUND' }) if (confirmation.token !== input.token) { @@ -420,6 +438,22 @@ export const finalistRouter = router({ reason: input.reason ?? null, }, }) + // Admin alert — best-effort, never throws + try { + await notifyAdmins({ + type: NotificationTypes.FINALIST_DECLINED, + title: 'Finalist declined', + message: `"${confirmation.project.title}" (${confirmation.category}) has declined grand-finale attendance.`, + linkUrl: '/admin/logistics', + metadata: { + projectId: confirmation.projectId, + projectTitle: confirmation.project.title, + category: confirmation.category, + }, + }) + } catch (err) { + console.error('[finalist.decline] failed to send admin notification:', err) + } // Promote next waitlist entry in same category. windowHours pulled from // the live grand-finale round in the program (LIVE_FINAL roundType). @@ -525,7 +559,7 @@ export const finalistRouter = router({ .mutation(async ({ ctx, input }) => { const confirmation = await ctx.prisma.finalistConfirmation.findUniqueOrThrow({ where: { id: input.confirmationId }, - include: { project: { select: { programId: true } } }, + include: { project: { select: { programId: true, title: true } } }, }) if (confirmation.status !== 'PENDING') { throw new TRPCError({ @@ -552,6 +586,22 @@ export const finalistRouter = router({ reason: input.reason ?? null, }, }) + // Admin alert — best-effort, never throws + try { + await notifyAdmins({ + type: NotificationTypes.FINALIST_DECLINED, + title: 'Finalist declined (admin)', + message: `"${confirmation.project.title}" (${confirmation.category}) was declined by an admin.`, + linkUrl: '/admin/logistics', + metadata: { + projectId: confirmation.projectId, + projectTitle: confirmation.project.title, + category: confirmation.category, + }, + }) + } catch (err) { + console.error('[finalist.adminDecline] failed to send admin notification:', err) + } const round = await ctx.prisma.round.findFirst({ where: { @@ -908,6 +958,23 @@ export const finalistRouter = router({ windowHours: input.windowHours, }, }) + // Admin alert — best-effort, never throws + try { + const projectTitle = project?.title ?? entry.projectId + await notifyAdmins({ + type: NotificationTypes.FINALIST_WAITLIST_PROMOTED, + title: 'Waitlist entry promoted', + message: `"${projectTitle}" (${entry.category}) was manually promoted from the waitlist.`, + linkUrl: '/admin/logistics', + metadata: { + projectId: entry.projectId, + projectTitle, + category: entry.category, + }, + }) + } catch (err) { + console.error('[finalist.manualPromote] failed to send admin notification:', err) + } return { confirmationId } }), diff --git a/src/server/services/finalist-confirmation.ts b/src/server/services/finalist-confirmation.ts index 65d8d60..a4c80b2 100644 --- a/src/server/services/finalist-confirmation.ts +++ b/src/server/services/finalist-confirmation.ts @@ -2,6 +2,7 @@ import type { CompetitionCategory, PrismaClient } from '@prisma/client' import { signFinalistToken } from '@/lib/finalist-token' import { sendFinalistConfirmationEmail } from '@/lib/email' import { logAudit } from '@/server/utils/audit' +import { notifyAdmins, NotificationTypes } from './in-app-notification' type AnyPrisma = Pick @@ -104,6 +105,27 @@ export async function promoteNextWaitlistEntry( } } + // Admin alert — best-effort, never throws + try { + const projectTitle = project?.title ?? entry.projectId + await notifyAdmins({ + type: NotificationTypes.FINALIST_WAITLIST_PROMOTED, + title: 'Waitlist entry promoted', + message: `"${projectTitle}" (${args.category}) was promoted from the waitlist.`, + linkUrl: '/admin/logistics', + metadata: { + projectId: entry.projectId, + projectTitle, + category: args.category, + }, + }) + } catch (err) { + console.error( + `[promoteNextWaitlistEntry] failed to send admin notification for project ${entry.projectId}:`, + err, + ) + } + return { promoted: true, entryId: entry.id, confirmationId } } @@ -131,6 +153,31 @@ export async function expirePendingPastDeadline( entityId: c.id, detailsJson: { projectId: c.projectId, category: c.category }, }) + // Admin alert — best-effort, never throws + try { + // Resolve project title for a meaningful notification message + const proj = await prisma.project.findUnique({ + where: { id: c.projectId }, + select: { title: true }, + }) + const projectTitle = proj?.title ?? c.projectId + await notifyAdmins({ + type: NotificationTypes.FINALIST_EXPIRED, + title: 'Finalist confirmation expired', + message: `"${projectTitle}" (${c.category}) did not confirm in time — confirmation expired.`, + linkUrl: '/admin/logistics', + metadata: { + projectId: c.projectId, + projectTitle, + category: c.category, + }, + }) + } catch (err) { + console.error( + `[expirePendingPastDeadline] failed to send admin notification for project ${c.projectId}:`, + err, + ) + } // Resolve windowHours for this program's grand-finale round const round = await prisma.round.findFirst({ where: { diff --git a/tests/unit/finalist-comms.test.ts b/tests/unit/finalist-comms.test.ts new file mode 100644 index 0000000..cb46982 --- /dev/null +++ b/tests/unit/finalist-comms.test.ts @@ -0,0 +1,255 @@ +/** + * Task 3: Admin alerts on confirmation lifecycle + * + * Verifies that in-app notifications are created for SUPER_ADMIN users + * on confirm, decline, and expiry events. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { prisma, createCaller } from '../setup' +import { + createTestUser, + createTestProgram, + createTestProject, + createTestCompetition, + createTestRound, + cleanupTestData, + uid, +} from '../helpers' +import { finalistRouter } from '../../src/server/routers/finalist' +import { expirePendingPastDeadline } from '../../src/server/services/finalist-confirmation' + +beforeAll(() => { + process.env.NEXTAUTH_SECRET = 'test-secret-for-finalist-tokens' + process.env.NEXTAUTH_URL = 'http://localhost:3001' +}) + +describe('finalist admin alert notifications (Task 3)', () => { + const programIds: string[] = [] + const userIds: string[] = [] + let adminUserId: string + + afterAll(async () => { + // Clean up notifications for admin + if (adminUserId) { + await prisma.inAppNotification.deleteMany({ where: { userId: adminUserId } }) + } + for (const programId of programIds) { + await prisma.inAppNotification.deleteMany({ + where: { + metadata: { path: ['projectId'], string_contains: '' }, + }, + }) + await prisma.attendingMember.deleteMany({ + where: { confirmation: { project: { programId } } }, + }) + await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } }) + await prisma.waitlistEntry.deleteMany({ where: { programId } }) + await cleanupTestData(programId, []) + } + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } + }) + + /** + * Set up a PENDING confirmation with a team lead + teammate, and a + * SUPER_ADMIN user to verify notifications against. + */ + async function setupWithAdmin(programName: string) { + // Create the admin once (shared) or per test + const admin = await prisma.user.create({ + data: { + id: uid('user'), + email: `admin_${uid()}@test.local`, + name: 'Super Admin', + role: 'SUPER_ADMIN', + roles: ['SUPER_ADMIN'], + status: 'ACTIVE', + }, + }) + userIds.push(admin.id) + adminUserId = admin.id + + const program = await createTestProgram({ name: programName }) + programIds.push(program.id) + + const lead = await prisma.user.create({ + data: { + id: uid('user'), + email: `lead_${uid()}@test.local`, + name: 'Team Lead', + role: 'APPLICANT', + roles: ['APPLICANT'], + status: 'ACTIVE', + }, + }) + userIds.push(lead.id) + const teammate = await prisma.user.create({ + data: { + id: uid('user'), + email: `mate_${uid()}@test.local`, + name: 'Teammate', + role: 'APPLICANT', + roles: ['APPLICANT'], + status: 'ACTIVE', + }, + }) + userIds.push(teammate.id) + + const project = await createTestProject(program.id, { + title: 'Notification Test Project', + competitionCategory: 'STARTUP', + }) + await prisma.teamMember.createMany({ + data: [ + { projectId: project.id, userId: lead.id, role: 'LEAD' }, + { projectId: project.id, userId: teammate.id, role: 'MEMBER' }, + ], + }) + + const competition = await createTestCompetition(program.id, { status: 'ACTIVE' }) + await createTestRound(competition.id, { + roundType: 'LIVE_FINAL', + configJson: { confirmationWindowHours: 24 }, + }) + + // Use the admin caller to selectFinalists and get a PENDING confirmation + const adminCaller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const round = await prisma.round.findFirstOrThrow({ + where: { competition: { programId: program.id } }, + }) + await adminCaller.selectFinalists({ + programId: program.id, + category: 'STARTUP', + projectIds: [project.id], + roundId: round.id, + }) + const confirmation = await prisma.finalistConfirmation.findUniqueOrThrow({ + where: { projectId: project.id }, + }) + + return { admin, program, lead, teammate, project, confirmation, round } + } + + it('Test 1: finalist.confirm fires FINALIST_CONFIRMED notification to admin', async () => { + const { admin, lead, teammate, confirmation } = await setupWithAdmin( + `comms-confirm-${uid()}`, + ) + + // Clear any existing notifications for this admin before the action + await prisma.inAppNotification.deleteMany({ where: { userId: admin.id } }) + + const publicCaller = finalistRouter.createCaller({ + session: null, + prisma, + ip: '127.0.0.1', + userAgent: 'vitest', + } as never) + + await publicCaller.confirm({ + token: confirmation.token, + attendingUserIds: [lead.id, teammate.id], + visaFlags: {}, + }) + + const notification = await prisma.inAppNotification.findFirst({ + where: { + userId: admin.id, + type: 'FINALIST_CONFIRMED', + }, + }) + expect(notification).not.toBeNull() + expect(notification?.type).toBe('FINALIST_CONFIRMED') + }) + + it('Test 2: finalist.decline fires FINALIST_DECLINED notification to admin', async () => { + const { admin, confirmation } = await setupWithAdmin(`comms-decline-${uid()}`) + + // Clear existing notifications + await prisma.inAppNotification.deleteMany({ where: { userId: admin.id } }) + + const publicCaller = finalistRouter.createCaller({ + session: null, + prisma, + ip: '127.0.0.1', + userAgent: 'vitest', + } as never) + + await publicCaller.decline({ token: confirmation.token, reason: 'Cannot attend' }) + + const notification = await prisma.inAppNotification.findFirst({ + where: { + userId: admin.id, + type: 'FINALIST_DECLINED', + }, + }) + expect(notification).not.toBeNull() + expect(notification?.type).toBe('FINALIST_DECLINED') + }) + + it('Test 3: expirePendingPastDeadline fires FINALIST_EXPIRED notification to admin', async () => { + // Create a fresh admin for this test (isolated) + const admin = await prisma.user.create({ + data: { + id: uid('user'), + email: `admin_expire_${uid()}@test.local`, + name: 'Super Admin Expire', + role: 'SUPER_ADMIN', + roles: ['SUPER_ADMIN'], + status: 'ACTIVE', + }, + }) + userIds.push(admin.id) + + const program = await createTestProgram({ name: `comms-expire-${uid()}` }) + programIds.push(program.id) + + const project = await createTestProject(program.id, { + title: 'Expire Test Project', + competitionCategory: 'STARTUP', + }) + + const competition = await createTestCompetition(program.id, { status: 'ACTIVE' }) + await createTestRound(competition.id, { + roundType: 'LIVE_FINAL', + configJson: { confirmationWindowHours: 24 }, + }) + + // Manually create a PENDING confirmation with a past deadline + const { signFinalistToken } = await import('../../src/lib/finalist-token') + const confirmationId = `cmfc_expire_${uid()}` + const expiredExp = Math.floor(Date.now() / 1000) - 60 + const token = signFinalistToken({ confirmationId, exp: expiredExp }) + await prisma.finalistConfirmation.create({ + data: { + id: confirmationId, + projectId: project.id, + category: 'STARTUP', + status: 'PENDING', + deadline: new Date(Date.now() - 60_000), + token, + }, + }) + + // Clear any existing notifications for this admin + await prisma.inAppNotification.deleteMany({ where: { userId: admin.id } }) + + await expirePendingPastDeadline(prisma) + + const notification = await prisma.inAppNotification.findFirst({ + where: { + userId: admin.id, + type: 'FINALIST_EXPIRED', + }, + }) + expect(notification).not.toBeNull() + expect(notification?.type).toBe('FINALIST_EXPIRED') + + // Cleanup this test's notifications + await prisma.inAppNotification.deleteMany({ where: { userId: admin.id } }) + }) +})