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:
@@ -19,6 +19,7 @@ import {
|
|||||||
resetOrCreatePendingConfirmation,
|
resetOrCreatePendingConfirmation,
|
||||||
confirmAttendanceInTx,
|
confirmAttendanceInTx,
|
||||||
} from '../services/finalist-enrollment'
|
} from '../services/finalist-enrollment'
|
||||||
|
import { sendManualFinalDocReminders } from '../services/final-documents'
|
||||||
|
|
||||||
export const finalistRouter = router({
|
export const finalistRouter = router({
|
||||||
/** List all per-category finalist slot quotas for a program. */
|
/** List all per-category finalist slot quotas for a program. */
|
||||||
@@ -1660,4 +1661,24 @@ export const finalistRouter = router({
|
|||||||
|
|
||||||
return { ok: true }
|
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
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PrismaClient } from '@prisma/client'
|
import type { PrismaClient } from '@prisma/client'
|
||||||
|
import { createNotification, NotificationTypes } from './in-app-notification'
|
||||||
|
|
||||||
export type FinalDocRequirement = {
|
export type FinalDocRequirement = {
|
||||||
id: string
|
id: string
|
||||||
@@ -85,3 +86,68 @@ export async function getFinalDocumentStatusForProject(
|
|||||||
allRequiredUploaded,
|
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 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import {
|
|||||||
cleanupTestData,
|
cleanupTestData,
|
||||||
uid,
|
uid,
|
||||||
} from '../helpers'
|
} 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 * as applicantRouter from '@/server/routers/applicant'
|
||||||
import { createCaller } from '../setup'
|
import { createCaller } from '../setup'
|
||||||
|
|
||||||
@@ -136,3 +139,27 @@ describe('applicant.getFinalDocumentStatus', () => {
|
|||||||
expect(await caller.getFinalDocumentStatus()).toBeNull()
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user