feat(final-docs): manual admin document-reminder blast

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-09 15:26:50 +02:00
parent f3d3a21156
commit 26709e2c9b
3 changed files with 115 additions and 1 deletions

View File

@@ -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
}),
})

View File

@@ -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 }
}