From 26709e2c9be2c0c3ffb7bd3df21ebb62bcacfc82 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 9 Jun 2026 15:26:50 +0200 Subject: [PATCH] feat(final-docs): manual admin document-reminder blast Co-Authored-By: Claude Opus 4.8 (1M context) --- src/server/routers/finalist.ts | 21 ++++++++ src/server/services/final-documents.ts | 66 ++++++++++++++++++++++++++ tests/unit/final-documents.test.ts | 29 ++++++++++- 3 files changed, 115 insertions(+), 1 deletion(-) diff --git a/src/server/routers/finalist.ts b/src/server/routers/finalist.ts index be3dfb9..ff92d71 100644 --- a/src/server/routers/finalist.ts +++ b/src/server/routers/finalist.ts @@ -19,6 +19,7 @@ import { resetOrCreatePendingConfirmation, confirmAttendanceInTx, } from '../services/finalist-enrollment' +import { sendManualFinalDocReminders } from '../services/final-documents' export const finalistRouter = router({ /** List all per-category finalist slot quotas for a program. */ @@ -1660,4 +1661,24 @@ export const finalistRouter = router({ return { ok: true } }), + + /** Manually remind finalist teams to upload their Grand Final documents. */ + sendDocumentReminders: adminProcedure + .input(z.object({ programId: z.string(), projectIds: z.array(z.string()).optional() })) + .mutation(async ({ ctx, input }) => { + const result = await sendManualFinalDocReminders(ctx.prisma, { + programId: input.programId, + projectIds: input.projectIds, + actorId: ctx.user.id, + }) + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'FINALIST_DOCS_REMINDER_SENT', + entityType: 'Program', + entityId: input.programId, + detailsJson: { sent: result.sent, projectIds: input.projectIds ?? 'all-missing' }, + }) + return result + }), }) diff --git a/src/server/services/final-documents.ts b/src/server/services/final-documents.ts index bb64670..a60a9b2 100644 --- a/src/server/services/final-documents.ts +++ b/src/server/services/final-documents.ts @@ -1,4 +1,5 @@ import type { PrismaClient } from '@prisma/client' +import { createNotification, NotificationTypes } from './in-app-notification' export type FinalDocRequirement = { id: string @@ -85,3 +86,68 @@ export async function getFinalDocumentStatusForProject( allRequiredUploaded, } } + +function baseUrl(): string { + return (process.env.NEXTAUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '') +} + +/** Build the reminder notification payload for one finalist team lead. */ +async function remindTeam( + prisma: PrismaClient, + args: { projectId: string; projectTitle: string; deadline: Date | null; missing: string[]; leadUserId: string }, +) { + await createNotification({ + userId: args.leadUserId, + type: NotificationTypes.GRAND_FINAL_DOCS_REMINDER, + title: 'Upload your Grand Final documents', + message: args.missing.length + ? `Still needed for "${args.projectTitle}": ${args.missing.join(', ')}.` + : `Please upload the final documents for "${args.projectTitle}".`, + linkUrl: `${baseUrl()}/applicant/documents`, + metadata: { + projectId: args.projectId, + projectTitle: args.projectTitle, + deadline: args.deadline?.toISOString(), + missing: args.missing, + }, + }) +} + +/** + * Manual admin reminder blast. Targets `projectIds` if given, else all finalist + * teams (enrolled in the active LIVE_FINAL round) with missing required docs. + */ +export async function sendManualFinalDocReminders( + prisma: PrismaClient, + opts: { programId: string; projectIds?: string[]; actorId: string }, +): Promise<{ sent: number }> { + const round = await getActiveFinaleRound(prisma, opts.programId) + if (!round) return { sent: 0 } + + const states = await prisma.projectRoundState.findMany({ + where: { roundId: round.id, ...(opts.projectIds ? { projectId: { in: opts.projectIds } } : {}) }, + select: { projectId: true }, + }) + + let sent = 0 + for (const { projectId } of states) { + const status = await getFinalDocumentStatusForProject(prisma, projectId) + if (!status) continue + const missing = status.requirements.filter((r) => r.isRequired && !r.uploaded).map((r) => r.name) + // When projectIds explicitly provided, send regardless; else only if missing docs. + if (!opts.projectIds && missing.length === 0) continue + + const project = await prisma.project.findUnique({ + where: { id: projectId }, + select: { title: true, teamMembers: { where: { role: 'LEAD' }, take: 1, select: { userId: true } } }, + }) + const leadUserId = project?.teamMembers[0]?.userId + if (!project || !leadUserId) continue + + await remindTeam(prisma, { + projectId, projectTitle: project.title, deadline: status.deadline, missing, leadUserId, + }) + sent++ + } + return { sent } +} diff --git a/tests/unit/final-documents.test.ts b/tests/unit/final-documents.test.ts index fd65871..f8ba5f7 100644 --- a/tests/unit/final-documents.test.ts +++ b/tests/unit/final-documents.test.ts @@ -10,7 +10,10 @@ import { cleanupTestData, uid, } from '../helpers' -import { getFinalDocumentStatusForProject } from '@/server/services/final-documents' +import { + getFinalDocumentStatusForProject, + sendManualFinalDocReminders, +} from '@/server/services/final-documents' import * as applicantRouter from '@/server/routers/applicant' import { createCaller } from '../setup' @@ -136,3 +139,27 @@ describe('applicant.getFinalDocumentStatus', () => { expect(await caller.getFinalDocumentStatus()).toBeNull() }) }) + +describe('sendManualFinalDocReminders', () => { + const localPrograms: string[] = [] + const localUsers: string[] = [] + afterAll(async () => { for (const id of localPrograms) await cleanupTestData(id, localUsers) }) + + it('sends a reminder only to finalist teams with missing required docs', async () => { + const program = await createTestProgram() + localPrograms.push(program.id) + const comp = await createTestCompetition(program.id, { status: 'ACTIVE' }) + const round = await createTestRound(comp.id, { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6, windowCloseAt: new Date(Date.now() + 86_400_000) }) + await prisma.fileRequirement.create({ data: { id: uid('req'), roundId: round.id, name: 'Executive Summary', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 } }) + const project = await createTestProject(program.id) + await createTestProjectRoundState(project.id, round.id) + const lead = await createTestUser('APPLICANT') + localUsers.push(lead.id) + await prisma.teamMember.create({ data: { projectId: project.id, userId: lead.id, role: 'LEAD' } }) + + const result = await sendManualFinalDocReminders(prisma, { programId: program.id, actorId: lead.id }) + expect(result.sent).toBe(1) + const notif = await prisma.inAppNotification.findFirst({ where: { userId: lead.id, type: 'GRAND_FINAL_DOCS_REMINDER' } }) + expect(notif).not.toBeNull() + }) +})