# Grand-Final Documents Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make the already-live Grand-Final document upload discoverable to the 9 confirmed finalists (banner + notifications), give the finale judges a read-only review page, surface the final documents in the mentor section (team + mentor), and add email + in-app reminders (auto cron + manual admin blast). **Architecture:** Thin additions on the existing legacy `FileRequirement` → `ProjectFile` anchor already configured on the active LIVE_FINAL round. A shared service `final-documents.ts` is the single source of truth for per-project document status; tRPC procedures wrap it. Notifications reuse the existing `createNotification` / `NotificationEmailSetting` / `NOTIFICATION_EMAIL_TEMPLATES` pipeline and the `sendDueConfirmationReminders` cron pattern. The judge page is a thin dedicated page (the finale has 0 per-project assignments and a group-based access model) that embeds server-generated presigned URLs because `file.getDownloadUrl` is assignment-gated for jurors. **Tech Stack:** Next.js 15 (App Router) · tRPC 11 · Prisma 6 / PostgreSQL · Vitest 4 · Tailwind/shadcn · MinIO presigned URLs · Nodemailer. **Eligibility rule (used everywhere):** a project is "a finalist for documents" iff it has a `ProjectRoundState` row in the program's **active `LIVE_FINAL`** round. This is the auto-enroll signal (verified: all 9 mentoring teams are confirmed + enrolled in the finale round) and the correct technical expression of "finalists moving to the grand finale." **Deadline (used everywhere):** the active LIVE_FINAL round's `windowCloseAt`. Soft/advisory — uploads stay open while the round is `ROUND_ACTIVE`; past the date the UI flags "late" but still accepts. Displayed in browser-local time + zone via `Intl.DateTimeFormat` (never hard-coded UTC/Monaco). --- ## File Structure **New files** - `src/server/services/final-documents.ts` — shared status/review/reminder logic (one responsibility: grand-final document state). - `src/components/applicant/final-documents-banner.tsx` — dashboard banner (team). - `src/components/applicant/final-documents-panel.tsx` — read-only "Final Documents" panel reused on the team mentor page **and** mentor workspace. - `src/components/admin/grand-finale/final-docs-reminder-button.tsx` — admin manual reminder button + preview dialog. - `src/app/(jury)/jury/finals-documents/page.tsx` — judge review page. - `src/app/api/cron/final-document-reminders/route.ts` — auto reminder cron. - `tests/unit/final-documents.test.ts` — service + procedure tests. **Modified files** - `src/server/services/in-app-notification.ts` — add `GRAND_FINAL_DOCS_REMINDER`, `GRAND_FINAL_DOCS_SUBMITTED` to `NotificationTypes`. - `src/lib/email.ts` — add two `NOTIFICATION_EMAIL_TEMPLATES` entries + builder functions. - `prisma/seed-notification-settings.ts` — add two rows. - `src/server/routers/applicant.ts` — `getFinalDocumentStatus` query; on-upload hook in `saveFileMetadata`. - `src/server/routers/finalist.ts` — `listReviewDocuments`, `sendDocumentReminders`. - `src/server/routers/mentor.ts` — `getProjectFinalDocuments`. - `src/app/(applicant)/applicant/page.tsx` — render the banner. - `src/app/(applicant)/applicant/mentor/page.tsx` — render the panel (team). - `src/app/(mentor)/mentor/workspace/[projectId]/page.tsx` — render the panel (mentor). - `prisma/schema.prisma` — add `FinalistConfirmation.finalDocsReminderSentAt` (Phase 4). - jury nav component + admin Grand-Final round overview — entry-point links. - `scripts/configure-grand-final-requirements.mjs` — guarded data script (new, untracked-style helper). **Phases (each ends shippable/committable):** 1. Foundation + finalist banner + manual reminder + requirements reconfig (most urgent). 2. Judge review page. 3. Mentor-section Final Documents panel (team + mentor). 4. Auto reminder cron + on-upload mentor notification + migration. --- ## PHASE 1 — Foundation + banner + manual reminder ### Task 1: Shared service — `getFinalDocumentStatusForProject` **Files:** - Create: `src/server/services/final-documents.ts` - Test: `tests/unit/final-documents.test.ts` - [ ] **Step 1: Write the failing test** ```ts // tests/unit/final-documents.test.ts import { describe, it, expect, afterAll } from 'vitest' import { prisma } from '../setup' import { createTestProgram, createTestCompetition, createTestRound, createTestProject, createTestProjectRoundState, cleanupTestData, uid, } from '../helpers' import { getFinalDocumentStatusForProject } from '@/server/services/final-documents' const programIds: string[] = [] async function makeFinaleProgram(opts: { roundStatus?: 'ROUND_ACTIVE' | 'ROUND_DRAFT'; closeAt?: Date } = {}) { const program = await createTestProgram() programIds.push(program.id) const comp = await createTestCompetition(program.id, { status: 'ACTIVE' }) const round = await createTestRound(comp.id, { roundType: 'LIVE_FINAL', status: opts.roundStatus ?? 'ROUND_ACTIVE', sortOrder: 6, windowCloseAt: opts.closeAt ?? new Date(Date.now() + 86_400_000), }) // Two PDF requirements + one video requirement const reqPlan = await prisma.fileRequirement.create({ data: { id: uid('req'), roundId: round.id, name: 'Final Business Plan', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 }, }) const reqVideo = await prisma.fileRequirement.create({ data: { id: uid('req'), roundId: round.id, name: '1-minute Video', acceptedMimeTypes: ['video/*'], isRequired: true, sortOrder: 2 }, }) return { program, comp, round, reqPlan, reqVideo } } describe('getFinalDocumentStatusForProject', () => { afterAll(async () => { for (const id of programIds) await cleanupTestData(id) }) it('returns null when the project is not enrolled in the active LIVE_FINAL round', async () => { const { program } = await makeFinaleProgram() const orphan = await createTestProject(program.id) const status = await getFinalDocumentStatusForProject(prisma, orphan.id) expect(status).toBeNull() }) it('returns per-requirement status with none uploaded', async () => { const { program, round } = await makeFinaleProgram() const project = await createTestProject(program.id) await createTestProjectRoundState(project.id, round.id) const status = await getFinalDocumentStatusForProject(prisma, project.id) expect(status).not.toBeNull() expect(status!.requirements).toHaveLength(2) expect(status!.requirements.every((r) => !r.uploaded)).toBe(true) expect(status!.allRequiredUploaded).toBe(false) expect(status!.deadline?.toISOString()).toBe(round.windowCloseAt!.toISOString()) }) it('marks a requirement uploaded and flips allRequiredUploaded when all present', async () => { const { program, round, reqPlan, reqVideo } = await makeFinaleProgram() const project = await createTestProject(program.id) await createTestProjectRoundState(project.id, round.id) for (const [req, type, mime] of [ [reqPlan, 'BUSINESS_PLAN', 'application/pdf'], [reqVideo, 'VIDEO', 'video/mp4'], ] as const) { await prisma.projectFile.create({ data: { id: uid('file'), projectId: project.id, roundId: round.id, requirementId: req.id, fileType: type as any, fileName: `f-${req.id}`, mimeType: mime, size: 10, bucket: 'b', objectKey: uid('key'), }, }) } const status = await getFinalDocumentStatusForProject(prisma, project.id) expect(status!.requirements.every((r) => r.uploaded)).toBe(true) expect(status!.allRequiredUploaded).toBe(true) }) it('returns null when the LIVE_FINAL round is not active', async () => { const { program, round } = await makeFinaleProgram({ roundStatus: 'ROUND_DRAFT' }) const project = await createTestProject(program.id) await createTestProjectRoundState(project.id, round.id) const status = await getFinalDocumentStatusForProject(prisma, project.id) expect(status).toBeNull() }) }) ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run tests/unit/final-documents.test.ts` Expected: FAIL — `getFinalDocumentStatusForProject` not exported / module not found. - [ ] **Step 3: Write minimal implementation** ```ts // src/server/services/final-documents.ts import type { PrismaClient } from '@prisma/client' export type FinalDocRequirement = { id: string name: string acceptedMimeTypes: string[] isRequired: boolean uploaded: boolean file: { id: string; fileName: string; mimeType: string; bucket: string; objectKey: string; createdAt: Date } | null } export type FinalDocumentStatus = { roundId: string roundName: string deadline: Date | null deadlinePassed: boolean requirements: FinalDocRequirement[] allRequiredUploaded: boolean } /** Resolve the program's active LIVE_FINAL round, or null. */ export async function getActiveFinaleRound(prisma: PrismaClient, programId: string) { return prisma.round.findFirst({ where: { competition: { programId }, roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE' }, orderBy: { sortOrder: 'desc' }, select: { id: true, name: true, windowCloseAt: true }, }) } /** * Per-project grand-final document status. Returns null unless the project is * enrolled (ProjectRoundState) in the program's active LIVE_FINAL round. */ export async function getFinalDocumentStatusForProject( prisma: PrismaClient, projectId: string, ): Promise { const project = await prisma.project.findUnique({ where: { id: projectId }, select: { id: true, programId: true }, }) if (!project) return null const round = await getActiveFinaleRound(prisma, project.programId) if (!round) return null const enrolled = await prisma.projectRoundState.findFirst({ where: { projectId, roundId: round.id }, select: { id: true }, }) if (!enrolled) return null const requirements = await prisma.fileRequirement.findMany({ where: { roundId: round.id }, orderBy: { sortOrder: 'asc' }, select: { id: true, name: true, acceptedMimeTypes: true, isRequired: true }, }) const files = await prisma.projectFile.findMany({ where: { projectId, requirementId: { in: requirements.map((r) => r.id) } }, orderBy: { createdAt: 'desc' }, select: { id: true, requirementId: true, fileName: true, mimeType: true, bucket: true, objectKey: true, createdAt: true }, }) const fileByReq = new Map() for (const f of files) if (f.requirementId && !fileByReq.has(f.requirementId)) fileByReq.set(f.requirementId, f) const reqStatuses: FinalDocRequirement[] = requirements.map((r) => { const f = fileByReq.get(r.id) ?? null return { id: r.id, name: r.name, acceptedMimeTypes: r.acceptedMimeTypes, isRequired: r.isRequired, uploaded: !!f, file: f ? { id: f.id, fileName: f.fileName, mimeType: f.mimeType, bucket: f.bucket, objectKey: f.objectKey, createdAt: f.createdAt } : null, } }) const allRequiredUploaded = reqStatuses.filter((r) => r.isRequired).every((r) => r.uploaded) const deadline = round.windowCloseAt ?? null return { roundId: round.id, roundName: round.name, deadline, deadlinePassed: deadline ? new Date() > deadline : false, requirements: reqStatuses, allRequiredUploaded, } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run tests/unit/final-documents.test.ts` Expected: PASS (4 tests). - [ ] **Step 5: Commit** ```bash git add src/server/services/final-documents.ts tests/unit/final-documents.test.ts git commit -m "feat(final-docs): grand-final document status service" ``` --- ### Task 2: `applicant.getFinalDocumentStatus` procedure **Files:** - Modify: `src/server/routers/applicant.ts` - Test: `tests/unit/final-documents.test.ts` - [ ] **Step 1: Add the failing test** (append to the existing describe block file; new describe) ```ts // tests/unit/final-documents.test.ts — add imports at top: import * as applicantRouter from '@/server/routers/applicant' import { createCaller } from '../setup' import { createTestUser } from '../helpers' describe('applicant.getFinalDocumentStatus', () => { const localPrograms: string[] = [] const localUsers: string[] = [] afterAll(async () => { for (const id of localPrograms) await cleanupTestData(id, localUsers) }) it('returns the status for the caller\'s enrolled finalist project', 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 user = await createTestUser('APPLICANT') localUsers.push(user.id) await prisma.teamMember.create({ data: { projectId: project.id, userId: user.id, role: 'LEAD' } }) const caller = createCaller(applicantRouter.applicantRouter, user) const status = await caller.getFinalDocumentStatus() expect(status?.roundId).toBe(round.id) expect(status?.requirements).toHaveLength(1) }) it('returns null when the caller has no project', async () => { const user = await createTestUser('APPLICANT') localUsers.push(user.id) const caller = createCaller(applicantRouter.applicantRouter, user) expect(await caller.getFinalDocumentStatus()).toBeNull() }) }) ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run tests/unit/final-documents.test.ts -t 'getFinalDocumentStatus'` Expected: FAIL — `caller.getFinalDocumentStatus is not a function`. - [ ] **Step 3: Add the procedure** In `src/server/routers/applicant.ts`, add the import near the top (with other service imports): ```ts import { getFinalDocumentStatusForProject } from '../services/final-documents' ``` Add this procedure inside the `applicantRouter` object (e.g. right after `getMyDashboard`): ```ts /** Grand-final document status for the caller's project (banner + mentor panel). */ getFinalDocumentStatus: protectedProcedure.query(async ({ ctx }) => { const project = await ctx.prisma.project.findFirst({ where: { OR: [ { submittedByUserId: ctx.user.id }, { teamMembers: { some: { userId: ctx.user.id } } }, ], }, orderBy: { createdAt: 'desc' }, select: { id: true }, }) if (!project) return null return getFinalDocumentStatusForProject(ctx.prisma, project.id) }), ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run tests/unit/final-documents.test.ts -t 'getFinalDocumentStatus'` Expected: PASS (2 tests). - [ ] **Step 5: Commit** ```bash git add src/server/routers/applicant.ts tests/unit/final-documents.test.ts git commit -m "feat(final-docs): applicant.getFinalDocumentStatus procedure" ``` --- ### Task 3: Finalist banner component + dashboard wiring **Files:** - Create: `src/components/applicant/final-documents-banner.tsx` - Modify: `src/app/(applicant)/applicant/page.tsx` - [ ] **Step 1: Write the banner component** ```tsx // src/components/applicant/final-documents-banner.tsx 'use client' import Link from 'next/link' import { trpc } from '@/lib/trpc/client' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { FileText, Video, CheckCircle2, Circle, Clock, Upload } from 'lucide-react' export function FinalDocumentsBanner() { const { data: status } = trpc.applicant.getFinalDocumentStatus.useQuery() if (!status) return null const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'short' }) const zone = new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' }) .formatToParts(new Date()).find((p) => p.type === 'timeZoneName')?.value const uploadedCount = status.requirements.filter((r) => r.uploaded).length const total = status.requirements.length const done = status.allRequiredUploaded return (
{done ? : } {done ? 'Grand Final documents submitted' : 'Upload your Grand Final documents'} ({uploadedCount} of {total})
{status.deadline && ( {status.deadlinePassed ? 'Deadline passed' : 'Due'}: {fmt.format(new Date(status.deadline))} {zone ? ` (${zone})` : ''} )}
{status.requirements.map((r) => { const Icon = r.acceptedMimeTypes.some((m) => m.startsWith('video/')) ? Video : FileText return ( {r.uploaded ? : } {r.name} ) })}
{!done && ( )}
) } ``` - [ ] **Step 2: Render it on the dashboard** In `src/app/(applicant)/applicant/page.tsx`, add the import with the other applicant-component imports: ```tsx import { FinalDocumentsBanner } from '@/components/applicant/final-documents-banner' ``` Render it near the top of the authenticated dashboard body, above the main content (it self-hides via `return null`). Place it just before the first `` in the main column: ```tsx ``` - [ ] **Step 3: Typecheck + build** Run: `npm run typecheck` Expected: no errors. - [ ] **Step 4: Commit** ```bash git add src/components/applicant/final-documents-banner.tsx "src/app/(applicant)/applicant/page.tsx" git commit -m "feat(final-docs): finalist upload banner on applicant dashboard" ``` --- ### Task 4: Notification type + email template + seed row (`GRAND_FINAL_DOCS_REMINDER`) **Files:** - Modify: `src/server/services/in-app-notification.ts` - Modify: `src/lib/email.ts` - Modify: `prisma/seed-notification-settings.ts` - [ ] **Step 1: Add the notification type** In `src/server/services/in-app-notification.ts`, inside the `NotificationTypes` object (in the logistics group, after `VISA_STATUS_UPDATE`), add: ```ts GRAND_FINAL_DOCS_REMINDER: 'GRAND_FINAL_DOCS_REMINDER', GRAND_FINAL_DOCS_SUBMITTED: 'GRAND_FINAL_DOCS_SUBMITTED', ``` - [ ] **Step 2: Add the email template builder** In `src/lib/email.ts`, add this builder near `getFinalistReminderTemplate` (reusing the existing `sectionTitle`, `paragraph`, `infoBox`, `ctaButton`, `escapeHtml`, `getEmailWrapper` helpers): ```ts function getGrandFinalDocsReminderTemplate( name: string, projectTitle: string, deadline: Date | null, uploadUrl: string, missing: string[], ): EmailTemplate { const greeting = name ? `Hi ${name},` : 'Hi there,' const formattedDeadline = deadline ? deadline.toLocaleString('en-GB', { timeZone: 'Europe/Paris', dateStyle: 'full', timeStyle: 'short' }) : null const missingLine = missing.length ? `Still needed: ${escapeHtml(missing.join(', '))}.` : 'Please make sure all required documents are uploaded.' const content = ` ${sectionTitle('Grand Final — Final Documents')} ${paragraph(greeting)} ${paragraph(`Please upload the final documents for ${escapeHtml(projectTitle)} ahead of the Monaco Ocean Protection Challenge Grand Finale.`)} ${paragraph(missingLine)} ${formattedDeadline ? infoBox(`Deadline: ${escapeHtml(formattedDeadline)} (Paris time).`, 'warning') : ''} ${ctaButton(uploadUrl, 'Upload documents')} ${paragraph('If you have any questions, please reach out to the MOPC team.')} ` const text = [ greeting, '', `Please upload the final documents for "${projectTitle}" ahead of the Grand Finale.`, missing.length ? `Still needed: ${missing.join(', ')}.` : 'Please make sure all required documents are uploaded.', formattedDeadline ? `Deadline: ${formattedDeadline} (Paris time).` : '', `Upload documents: ${uploadUrl}`, '', 'The MOPC team', ].join('\n') return { subject: 'Action needed: upload your Grand Final documents', html: getEmailWrapper(content), text } } ``` Register it in the `NOTIFICATION_EMAIL_TEMPLATES` map (alongside `FINALIST_REMINDER`): ```ts GRAND_FINAL_DOCS_REMINDER: (ctx) => getGrandFinalDocsReminderTemplate( ctx.name || '', (ctx.metadata?.projectTitle as string) || 'Your project', ctx.metadata?.deadline ? new Date(ctx.metadata.deadline as string) : null, ctx.linkUrl || '', (ctx.metadata?.missing as string[]) || [], ), ``` - [ ] **Step 3: Add seed rows** In `prisma/seed-notification-settings.ts`, add two rows to the settings array (near the logistics rows): ```ts { notificationType: 'GRAND_FINAL_DOCS_REMINDER', category: 'logistics', label: 'Final Documents Reminder', description: 'Reminder to finalist teams to upload their Grand Final documents before the deadline', sendEmail: true, }, { notificationType: 'GRAND_FINAL_DOCS_SUBMITTED', category: 'logistics', label: 'Final Documents Submitted', description: 'Notifies the team mentor when a finalist uploads a Grand Final document', sendEmail: false, }, ``` - [ ] **Step 4: Apply seed locally + typecheck** Run: `npx tsx prisma/seed-notification-settings.ts && npm run typecheck` Expected: settings upserted, no type errors. - [ ] **Step 5: Commit** ```bash git add src/server/services/in-app-notification.ts src/lib/email.ts prisma/seed-notification-settings.ts git commit -m "feat(final-docs): GRAND_FINAL_DOCS_REMINDER/SUBMITTED notification types + email template" ``` --- ### Task 5: Manual reminder service + `finalist.sendDocumentReminders` **Files:** - Modify: `src/server/services/final-documents.ts` - Modify: `src/server/routers/finalist.ts` - Test: `tests/unit/final-documents.test.ts` - [ ] **Step 1: Write the failing test** ```ts // tests/unit/final-documents.test.ts — add: import * as finalistRouter from '@/server/routers/finalist' import { sendManualFinalDocReminders } from '@/server/services/final-documents' 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() }) }) ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run tests/unit/final-documents.test.ts -t 'sendManualFinalDocReminders'` Expected: FAIL — function not exported. - [ ] **Step 3: Implement the service function** Append to `src/server/services/final-documents.ts`: ```ts import { createNotification, NotificationTypes } from './in-app-notification' 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 } } ``` - [ ] **Step 4: Add the tRPC procedure** In `src/server/routers/finalist.ts`, add the import: ```ts import { sendManualFinalDocReminders } from '../services/final-documents' ``` Add the procedure inside `finalistRouter`: ```ts /** 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(ctx, { action: 'FINALIST_DOCS_REMINDER_SENT', entityType: 'Program', entityId: input.programId, metadata: { sent: result.sent, projectIds: input.projectIds ?? 'all-missing' }, }) return result }), ``` > Note: `logAudit`'s exact signature is already imported at the top of `finalist.ts`. Match the existing call shape used by other procedures in this file (check `unconfirm`/`adminConfirm`); adjust the object keys if the local signature differs. - [ ] **Step 5: Run test to verify it passes** Run: `npx vitest run tests/unit/final-documents.test.ts -t 'sendManualFinalDocReminders'` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add src/server/services/final-documents.ts src/server/routers/finalist.ts tests/unit/final-documents.test.ts git commit -m "feat(final-docs): manual admin document-reminder blast" ``` --- ### Task 6: Admin reminder button + preview dialog **Files:** - Create: `src/components/admin/grand-finale/final-docs-reminder-button.tsx` - Modify: admin Grand-Final round overview (locate the file that renders `finalist-enrollment-card.tsx`, e.g. `src/app/(admin)/admin/rounds/[roundId]/page.tsx` or the finale overview section) to render the button. - [ ] **Step 1: Write the button component** ```tsx // src/components/admin/grand-finale/final-docs-reminder-button.tsx 'use client' import { useState } from 'react' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { EmailPreviewDialog } from '@/components/admin/notifications/email-preview-dialog' import { Mail, Send } from 'lucide-react' import { toast } from 'sonner' export function FinalDocsReminderButton({ programId }: { programId: string }) { const [open, setOpen] = useState(false) const send = trpc.finalist.sendDocumentReminders.useMutation({ onSuccess: (r) => { toast.success(`Reminder sent to ${r.sent} team${r.sent === 1 ? '' : 's'}`); setOpen(false) }, onError: (e) => toast.error(e.message), }) return ( Remind finalist teams Sends an in-app + email reminder to every finalist team with missing required documents. ) } ``` > **Implementation note:** confirm `EmailPreviewDialog`'s real prop name for the type (it may be `type` / `templateType` rather than `notificationType`, and may need `category`/sample-data props). Match the usage already present in the logistics "Email Templates" tab. If inline embedding is awkward, drop the preview and keep just the send button — the preview is optional polish. - [ ] **Step 2: Render the button on the admin finale overview** Add near the `FinalistEnrollmentCard` usage: ```tsx import { FinalDocsReminderButton } from '@/components/admin/grand-finale/final-docs-reminder-button' // ... ``` - [ ] **Step 3: Typecheck** Run: `npm run typecheck` Expected: no errors. - [ ] **Step 4: Commit** ```bash git add src/components/admin/grand-finale/final-docs-reminder-button.tsx git commit -m "feat(final-docs): admin manual reminder button + preview" ``` --- ### Task 7: Reconfigure the 4 file requirements (data script) **Files:** - Create: `scripts/configure-grand-final-requirements.mjs` - [ ] **Step 1: Write the guarded, idempotent script** ```js // scripts/configure-grand-final-requirements.mjs // Usage: node scripts/configure-grand-final-requirements.mjs (dry-run, prints plan) // node scripts/configure-grand-final-requirements.mjs --apply (writes) import { PrismaClient } from '@prisma/client' const p = new PrismaClient() const APPLY = process.argv.includes('--apply') const TARGET = [ { name: 'Final Presentation', acceptedMimeTypes: ['application/pdf'], sortOrder: 1, renameFrom: 'PDF presentation support' }, { name: 'Final Business Plan', acceptedMimeTypes: ['application/pdf'], sortOrder: 2 }, { name: '1-minute Video', acceptedMimeTypes: ['video/*'], sortOrder: 3, renameFrom: '1 minute video' }, { name: 'Executive Summary', acceptedMimeTypes: ['application/pdf'], sortOrder: 4 }, ] const run = async () => { const round = await p.round.findFirst({ where: { roundType: 'LIVE_FINAL' }, orderBy: { sortOrder: 'desc' } }) if (!round) throw new Error('No LIVE_FINAL round') const existing = await p.fileRequirement.findMany({ where: { roundId: round.id } }) console.log(`Round "${round.name}" (${round.id}); existing reqs: ${existing.map((r) => r.name).join(', ') || 'none'}`) for (const t of TARGET) { const match = existing.find((r) => r.name === t.name || (t.renameFrom && r.name === t.renameFrom)) if (match) { console.log(`UPDATE "${match.name}" -> name="${t.name}" required=true sort=${t.sortOrder} mimes=${t.acceptedMimeTypes}`) if (APPLY) await p.fileRequirement.update({ where: { id: match.id }, data: { name: t.name, acceptedMimeTypes: t.acceptedMimeTypes, isRequired: true, sortOrder: t.sortOrder } }) } else { console.log(`CREATE "${t.name}" required=true sort=${t.sortOrder} mimes=${t.acceptedMimeTypes}`) if (APPLY) await p.fileRequirement.create({ data: { roundId: round.id, name: t.name, acceptedMimeTypes: t.acceptedMimeTypes, isRequired: true, sortOrder: t.sortOrder } }) } } console.log(APPLY ? 'APPLIED.' : 'DRY-RUN (pass --apply to write).') } run().catch((e) => { console.error(e); process.exit(1) }).finally(() => p.$disconnect()) ``` - [ ] **Step 2: Dry-run against dev** Run: `node scripts/configure-grand-final-requirements.mjs` Expected: prints the UPDATE/CREATE plan; no writes. - [ ] **Step 3: Apply on dev** Run: `node scripts/configure-grand-final-requirements.mjs --apply` Expected: "APPLIED." (Prod application happens at deploy time — see Deployment.) - [ ] **Step 4: Commit** ```bash git add scripts/configure-grand-final-requirements.mjs git commit -m "chore(final-docs): script to configure the 4 grand-final file requirements" ``` **Phase 1 checkpoint:** `npm run build` + `npx vitest run tests/unit/final-documents.test.ts` green. Phase 1 is independently shippable (banner + manual reminder + requirements). No migration required. --- ## PHASE 2 — Judge review page ### Task 8: Review service + access helper + `finalist.listReviewDocuments` **Files:** - Modify: `src/server/services/final-documents.ts` - Modify: `src/server/routers/finalist.ts` - Test: `tests/unit/final-documents.test.ts` - [ ] **Step 1: Write the failing tests (data shape + auth matrix)** ```ts // tests/unit/final-documents.test.ts — add: import { TRPCError } from '@trpc/server' describe('finalist.listReviewDocuments', () => { const localPrograms: string[] = [] const localUsers: string[] = [] afterAll(async () => { for (const id of localPrograms) await cleanupTestData(id, localUsers) }) async function setup() { const program = await createTestProgram() localPrograms.push(program.id) const comp = await createTestCompetition(program.id, { status: 'ACTIVE' }) const jg = await prisma.juryGroup.create({ data: { id: uid('jg'), competitionId: comp.id, name: 'Finals Jury', slug: uid('jg') } }) const round = await createTestRound(comp.id, { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6, windowCloseAt: new Date(Date.now() + 86_400_000) }) await prisma.round.update({ where: { id: round.id }, data: { juryGroupId: jg.id } }) 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, { competitionCategory: 'STARTUP' }) await createTestProjectRoundState(project.id, round.id) return { program, comp, jg, round, project } } it('admin sees all finalist teams', async () => { const { program } = await setup() const admin = await createTestUser('PROGRAM_ADMIN'); localUsers.push(admin.id) const caller = createCaller(finalistRouter.finalistRouter, admin) const result = await caller.listReviewDocuments({ programId: program.id }) expect(result.teams).toHaveLength(1) expect(result.totalCount).toBe(1) }) it('a finals jury-group member is allowed', async () => { const { program, jg } = await setup() const juror = await createTestUser('JURY_MEMBER'); localUsers.push(juror.id) await prisma.juryGroupMember.create({ data: { juryGroupId: jg.id, userId: juror.id, role: 'MEMBER' } }) const caller = createCaller(finalistRouter.finalistRouter, juror) const result = await caller.listReviewDocuments({ programId: program.id }) expect(result.teams).toHaveLength(1) }) it('a non-finals jury member is forbidden', async () => { const { program } = await setup() const juror = await createTestUser('JURY_MEMBER'); localUsers.push(juror.id) const caller = createCaller(finalistRouter.finalistRouter, juror) await expect(caller.listReviewDocuments({ programId: program.id })).rejects.toThrow(TRPCError) }) }) ``` - [ ] **Step 2: Run to verify it fails** Run: `npx vitest run tests/unit/final-documents.test.ts -t 'listReviewDocuments'` Expected: FAIL — procedure missing. - [ ] **Step 3: Implement the review service** Append to `src/server/services/final-documents.ts`. The presigned-URL helper must match what `file.getDownloadUrl` uses — import the same function from `@/lib/minio` (verify the exact export name; the upload path uses `getPresignedUrl(bucket, objectKey, 'PUT', 3600)`, so the GET form is `getPresignedUrl(bucket, objectKey, 'GET', 3600)`): ```ts import { getPresignedUrl } from '@/lib/minio' export type ReviewDocument = { requirementId: string; requirementName: string; file: { id: string; fileName: string; mimeType: string; url: string } | null } export type ReviewTeam = { projectId: string; teamName: string; category: string | null; documents: ReviewDocument[]; submitted: boolean } export type ReviewPayload = { round: { id: string; name: string; deadline: Date | null }; totalCount: number; submittedCount: number; teams: ReviewTeam[] } export async function listFinalistDocumentsForReview(prisma: PrismaClient, programId: string): Promise { const round = await getActiveFinaleRound(prisma, programId) if (!round) return { round: { id: '', name: '', deadline: null }, totalCount: 0, submittedCount: 0, teams: [] } const requirements = await prisma.fileRequirement.findMany({ where: { roundId: round.id }, orderBy: { sortOrder: 'asc' }, select: { id: true, name: true } }) const states = await prisma.projectRoundState.findMany({ where: { roundId: round.id }, select: { project: { select: { id: true, title: true, teamName: true, competitionCategory: true } } }, }) const teams: ReviewTeam[] = [] for (const { project } of states) { const files = await prisma.projectFile.findMany({ where: { projectId: project.id, requirementId: { in: requirements.map((r) => r.id) } }, orderBy: { createdAt: 'desc' }, select: { id: true, requirementId: true, fileName: true, mimeType: true, bucket: true, objectKey: true }, }) const byReq = new Map() for (const f of files) if (f.requirementId && !byReq.has(f.requirementId)) byReq.set(f.requirementId, f) const documents: ReviewDocument[] = [] for (const r of requirements) { const f = byReq.get(r.id) documents.push({ requirementId: r.id, requirementName: r.name, file: f ? { id: f.id, fileName: f.fileName, mimeType: f.mimeType, url: await getPresignedUrl(f.bucket, f.objectKey, 'GET', 3600) } : null, }) } teams.push({ projectId: project.id, teamName: project.teamName || project.title, category: project.competitionCategory, documents, submitted: documents.every((d) => d.file !== null), }) } teams.sort((a, b) => (a.category || '').localeCompare(b.category || '') || a.teamName.localeCompare(b.teamName)) return { round: { id: round.id, name: round.name, deadline: round.windowCloseAt ?? null }, totalCount: teams.length, submittedCount: teams.filter((t) => t.submitted).length, teams, } } /** True if user is admin or a member of the program's active LIVE_FINAL jury group. */ export async function userCanReviewFinals(prisma: PrismaClient, userId: string, userRole: string, programId: string): Promise { if (userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN') return true const round = await prisma.round.findFirst({ where: { competition: { programId }, roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE' }, orderBy: { sortOrder: 'desc' }, select: { juryGroupId: true }, }) if (!round?.juryGroupId) return false const member = await prisma.juryGroupMember.findFirst({ where: { juryGroupId: round.juryGroupId, userId }, select: { id: true } }) return !!member } ``` - [ ] **Step 4: Add the tRPC procedure** In `src/server/routers/finalist.ts`, extend the import: ```ts import { sendManualFinalDocReminders, listFinalistDocumentsForReview, userCanReviewFinals } from '../services/final-documents' ``` Add the procedure (uses `protectedProcedure` since finale jurors must reach it — auth is done inside): ```ts /** Read-only review of all finalists' grand-final documents (admins + finale jury). */ listReviewDocuments: protectedProcedure .input(z.object({ programId: z.string() })) .query(async ({ ctx, input }) => { const allowed = await userCanReviewFinals(ctx.prisma, ctx.user.id, ctx.user.role, input.programId) if (!allowed) throw new TRPCError({ code: 'FORBIDDEN', message: 'You do not have access to the finalist documents review.' }) return listFinalistDocumentsForReview(ctx.prisma, input.programId) }), ``` - [ ] **Step 5: Run tests to verify they pass** Run: `npx vitest run tests/unit/final-documents.test.ts -t 'listReviewDocuments'` Expected: PASS (3 tests). - [ ] **Step 6: Commit** ```bash git add src/server/services/final-documents.ts src/server/routers/finalist.ts tests/unit/final-documents.test.ts git commit -m "feat(final-docs): finalist document review service + procedure with finale-access gate" ``` --- ### Task 9: Judge review page + entry points **Files:** - Create: `src/app/(jury)/jury/finals-documents/page.tsx` - Modify: jury nav/sidebar component; admin finale overview (add a link). - [ ] **Step 1: Resolve the caller's programId** The page needs a `programId`. Reuse whatever the jury area already uses to resolve the active program (look for an existing `trpc.*` query the jury dashboard uses, e.g. a "my active competition/program" query). If none exists, add a tiny `protectedProcedure` `competition.getActiveProgramId` that returns the most recent ACTIVE program id. Document the exact query chosen here before writing the page. - [ ] **Step 2: Write the page** ```tsx // src/app/(jury)/jury/finals-documents/page.tsx 'use client' import { trpc } from '@/lib/trpc/client' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { FilePreview } from '@/components/shared/file-viewer' import { FileText, Download, ShieldAlert } from 'lucide-react' export default function FinalsDocumentsPage() { // Replace with the resolved active-program query from Step 1: const { data: programId } = trpc.competition.getActiveProgramId.useQuery() const { data, isLoading, error } = trpc.finalist.listReviewDocuments.useQuery( { programId: programId! }, { enabled: !!programId, retry: false }, ) if (error?.data?.code === 'FORBIDDEN') { return (

No access

This review is for the Grand-Final jury and program admins.

) } if (isLoading || !data) return
const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'short' }) return (

Finalist Documents

{data.submittedCount} of {data.totalCount} teams complete {data.round.deadline ? ` · due ${fmt.format(new Date(data.round.deadline))}` : ''}

{data.teams.map((team) => ( {team.teamName}
{team.category && {team.category}} {team.submitted ? 'Complete' : 'Incomplete'}
{team.documents.map((doc) => (
{doc.requirementName} {doc.file && ( )}
{doc.file ? ( ) : (

Not yet uploaded

)}
))}
))}
) } ``` > **Implementation note:** verify `FilePreview`'s exact prop shape (from `src/components/shared/file-viewer.tsx`) — the applicant documents page calls it as ``, so this matches. Confirm the `(jury)` layout does not redirect admins; if it does, also add an admin entry under `(admin)` pointing to the same component, or move the page to a neutral route group. The `finalist.listReviewDocuments` gate is the real authorization. - [ ] **Step 3: Add entry-point links** - Jury sidebar/nav: add a "Finalist Documents" link to `/jury/finals-documents` (find the jury nav component, e.g. under `src/components/jury/` or the `(jury)` layout). Show it unconditionally for jurors; the page self-gates. - Admin finale overview: add a link/button to `/jury/finals-documents` ("Review finalist documents") near the enrollment card. - [ ] **Step 4: Typecheck + manual smoke** Run: `npm run typecheck` Expected: no errors. - [ ] **Step 5: Commit** ```bash git add "src/app/(jury)/jury/finals-documents/page.tsx" git commit -m "feat(final-docs): judge review page + entry points" ``` **Phase 2 checkpoint:** `npm run build` green. Independently shippable. --- ## PHASE 3 — Mentor-section Final Documents panel ### Task 10: `mentor.getProjectFinalDocuments` procedure **Files:** - Modify: `src/server/routers/mentor.ts` - Test: `tests/unit/final-documents.test.ts` - [ ] **Step 1: Write the failing test** ```ts // tests/unit/final-documents.test.ts — add: import * as mentorRouter from '@/server/routers/mentor' describe('mentor.getProjectFinalDocuments', () => { const localPrograms: string[] = [] const localUsers: string[] = [] afterAll(async () => { for (const id of localPrograms) await cleanupTestData(id, localUsers) }) it('returns status for a project the mentor is assigned to', 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 mentor = await createTestUser('MENTOR'); localUsers.push(mentor.id) await prisma.mentorAssignment.create({ data: { projectId: project.id, mentorId: mentor.id } }) const caller = createCaller(mentorRouter.mentorRouter, mentor) const status = await caller.getProjectFinalDocuments({ projectId: project.id }) expect(status?.roundId).toBe(round.id) }) it('forbids a mentor not assigned to the project', 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 }) const project = await createTestProject(program.id) await createTestProjectRoundState(project.id, round.id) const mentor = await createTestUser('MENTOR'); localUsers.push(mentor.id) const caller = createCaller(mentorRouter.mentorRouter, mentor) await expect(caller.getProjectFinalDocuments({ projectId: project.id })).rejects.toThrow() }) }) ``` - [ ] **Step 2: Run to verify it fails** Run: `npx vitest run tests/unit/final-documents.test.ts -t 'getProjectFinalDocuments'` Expected: FAIL. - [ ] **Step 3: Add the procedure** In `src/server/routers/mentor.ts`, add the import: ```ts import { getFinalDocumentStatusForProject } from '../services/final-documents' ``` Add the procedure. Reuse the file's existing access guard if present (e.g. `assertProjectWorkspaceAccess`); otherwise inline the check (mentor assignment OR team membership OR admin): ```ts /** Grand-final document status for a project, for the mentor workspace panel. */ getProjectFinalDocuments: protectedProcedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role) if (!isAdmin) { const [mentorAssignment, teamMembership] = await Promise.all([ ctx.prisma.mentorAssignment.findFirst({ where: { mentorId: ctx.user.id, projectId: input.projectId }, select: { id: true } }), ctx.prisma.teamMember.findFirst({ where: { userId: ctx.user.id, projectId: input.projectId }, select: { id: true } }), ]) if (!mentorAssignment && !teamMembership) throw new TRPCError({ code: 'FORBIDDEN', message: 'No access to this project' }) } return getFinalDocumentStatusForProject(ctx.prisma, input.projectId) }), ``` > Note: ensure `TRPCError` and `z` are imported in `mentor.ts` (they almost certainly are). - [ ] **Step 4: Run to verify it passes** Run: `npx vitest run tests/unit/final-documents.test.ts -t 'getProjectFinalDocuments'` Expected: PASS (2 tests). - [ ] **Step 5: Commit** ```bash git add src/server/routers/mentor.ts tests/unit/final-documents.test.ts git commit -m "feat(final-docs): mentor.getProjectFinalDocuments procedure" ``` --- ### Task 11: Final Documents panel component (team + mentor) **Files:** - Create: `src/components/applicant/final-documents-panel.tsx` - Modify: `src/app/(applicant)/applicant/mentor/page.tsx` - Modify: `src/app/(mentor)/mentor/workspace/[projectId]/page.tsx` - [ ] **Step 1: Write the panel (accepts a `source` so it works on both sides)** ```tsx // src/components/applicant/final-documents-panel.tsx 'use client' import Link from 'next/link' import { trpc } from '@/lib/trpc/client' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { FileCheck2, Clock, Upload, CheckCircle2, Circle } from 'lucide-react' type Props = | { variant: 'team' } | { variant: 'mentor'; projectId: string } export function FinalDocumentsPanel(props: Props) { const teamQuery = trpc.applicant.getFinalDocumentStatus.useQuery(undefined, { enabled: props.variant === 'team' }) const mentorQuery = trpc.mentor.getProjectFinalDocuments.useQuery( { projectId: props.variant === 'mentor' ? props.projectId : '' }, { enabled: props.variant === 'mentor' }, ) const status = props.variant === 'team' ? teamQuery.data : mentorQuery.data if (!status) return null const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'short' }) return (
Final Documents {status.allRequiredUploaded ? Submitted : status.deadline && ( Due {fmt.format(new Date(status.deadline))} )}
{props.variant === 'team' ? 'Your final deliverables for the Grand Finale.' : 'This team\'s final deliverables for the Grand Finale.'}
{status.requirements.map((r) => (
{r.uploaded ? : } {r.name} {r.file?.fileName ?? 'Not yet uploaded'}
))} {props.variant === 'team' && !status.allRequiredUploaded && ( )}
) } ``` > The panel shows metadata + status. The team uploads via the existing `/applicant/documents` page (linked). Inline preview/download for the team already exists there; the mentor reviews documents on the dedicated judge page or admin project view. (If the mentor needs inline preview here later, add presigned URLs to `mentor.getProjectFinalDocuments` like the review service — out of scope for this task.) - [ ] **Step 2: Render on the team mentor page** In `src/app/(applicant)/applicant/mentor/page.tsx`, add the import and render the panel below the Files panel block: ```tsx import { FinalDocumentsPanel } from '@/components/applicant/final-documents-panel' // ... after the WorkspaceFilesPanel block: ``` - [ ] **Step 3: Render on the mentor workspace** In `src/app/(mentor)/mentor/workspace/[projectId]/page.tsx`, add the import and render the panel below the `` block (or as a new section above the tabs): ```tsx import { FinalDocumentsPanel } from '@/components/applicant/final-documents-panel' // ... below the Tabs: ``` - [ ] **Step 4: Typecheck** Run: `npm run typecheck` Expected: no errors. - [ ] **Step 5: Commit** ```bash git add src/components/applicant/final-documents-panel.tsx "src/app/(applicant)/applicant/mentor/page.tsx" "src/app/(mentor)/mentor/workspace/[projectId]/page.tsx" git commit -m "feat(final-docs): Final Documents panel on team + mentor views" ``` **Phase 3 checkpoint:** `npm run build` green. Independently shippable. --- ## PHASE 4 — Auto reminder cron + on-upload notification + migration ### Task 12: Migration — `FinalistConfirmation.finalDocsReminderSentAt` **Files:** - Modify: `prisma/schema.prisma` - Create: migration via Prisma. - [ ] **Step 1: Add the field** In `prisma/schema.prisma`, in `model FinalistConfirmation`, add next to `reminderSentAt`: ```prisma finalDocsReminderSentAt DateTime? ``` - [ ] **Step 2: Create the migration (dev DB must be clean — see memory `feedback_never_migrate_dev_on_drift`)** If the dev DB is in sync, run: `npx prisma migrate dev --name add_final_docs_reminder_sent_at` Expected: migration `..._add_final_docs_reminder_sent_at` created + applied; client regenerated. If dev DB is drifted, do NOT `migrate dev`. Instead: hand-write the migration SQL (`ALTER TABLE "FinalistConfirmation" ADD COLUMN "finalDocsReminderSentAt" TIMESTAMP(3);`), then `npx prisma db execute` it on dev and `npx prisma migrate resolve --applied `, then `npx prisma generate`. - [ ] **Step 3: Verify client + typecheck** Run: `npx prisma generate && npm run typecheck` Expected: no errors; `finalDocsReminderSentAt` available on the model. - [ ] **Step 4: Commit** ```bash git add prisma/schema.prisma prisma/migrations git commit -m "feat(final-docs): add FinalistConfirmation.finalDocsReminderSentAt" ``` --- ### Task 13: Auto reminder service + cron route **Files:** - Modify: `src/server/services/final-documents.ts` - Create: `src/app/api/cron/final-document-reminders/route.ts` - Test: `tests/unit/final-documents.test.ts` - [ ] **Step 1: Write the failing test** ```ts // tests/unit/final-documents.test.ts — add: import { sendDueFinalDocReminders } from '@/server/services/final-documents' describe('sendDueFinalDocReminders', () => { const localPrograms: string[] = [] const localUsers: string[] = [] afterAll(async () => { for (const id of localPrograms) await cleanupTestData(id, localUsers) }) it('reminds once when within the window and stamps finalDocsReminderSentAt', 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() + 3_600_000), // 1h out → within 48h window configJson: { finalDocsReminderHoursBeforeDeadline: 48 }, }) 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' } }) await prisma.finalistConfirmation.create({ data: { id: uid('fc'), projectId: project.id, status: 'CONFIRMED', category: 'STARTUP', deadline: new Date(Date.now() + 3_600_000), token: uid('tok') } }) const first = await sendDueFinalDocReminders(prisma) expect(first.remindersSent).toBe(1) const second = await sendDueFinalDocReminders(prisma) expect(second.remindersSent).toBe(0) // idempotent: already stamped }) }) ``` > Note: confirm `FinalistConfirmation` required fields (`category`, `token`, `deadline`) — adjust the create to satisfy the schema (the prod model uses `category` + `token` + `deadline`; see the verified schema). If `token` has a default, drop it. - [ ] **Step 2: Run to verify it fails** Run: `npx vitest run tests/unit/final-documents.test.ts -t 'sendDueFinalDocReminders'` Expected: FAIL. - [ ] **Step 3: Implement** Append to `src/server/services/final-documents.ts`: ```ts /** * Cron: remind finalist teams (enrolled in an active LIVE_FINAL round) with * missing required documents, once, when the deadline is within the configured * window. Stamps FinalistConfirmation.finalDocsReminderSentAt. */ export async function sendDueFinalDocReminders(prisma: PrismaClient): Promise<{ remindersSent: number }> { const now = new Date() const rounds = await prisma.round.findMany({ where: { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE' }, select: { id: true, windowCloseAt: true, configJson: true, competition: { select: { programId: true } } }, }) let remindersSent = 0 for (const round of rounds) { if (!round.windowCloseAt) continue const cfg = (round.configJson ?? {}) as { finalDocsReminderHoursBeforeDeadline?: number } const windowMs = (cfg.finalDocsReminderHoursBeforeDeadline ?? 48) * 3_600_000 const isDue = round.windowCloseAt.getTime() <= now.getTime() + windowMs && round.windowCloseAt.getTime() > now.getTime() if (!isDue) continue const states = await prisma.projectRoundState.findMany({ where: { roundId: round.id }, select: { projectId: true } }) for (const { projectId } of states) { const confirmation = await prisma.finalistConfirmation.findFirst({ where: { projectId, finalDocsReminderSentAt: null }, select: { id: true }, }) if (!confirmation) continue const status = await getFinalDocumentStatusForProject(prisma, projectId) if (!status) continue const missing = status.requirements.filter((r) => r.isRequired && !r.uploaded).map((r) => r.name) if (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 try { await remindTeam(prisma, { projectId, projectTitle: project.title, deadline: status.deadline, missing, leadUserId }) await prisma.finalistConfirmation.update({ where: { id: confirmation.id }, data: { finalDocsReminderSentAt: new Date() } }) remindersSent++ } catch (e) { console.error('[final-docs] reminder failed for', projectId, e) } } } return { remindersSent } } ``` - [ ] **Step 4: Create the cron route** ```ts // src/app/api/cron/final-document-reminders/route.ts import { NextResponse, type NextRequest } from 'next/server' import { prisma } from '@/lib/prisma' import { sendDueFinalDocReminders } from '@/server/services/final-documents' export async function GET(request: NextRequest): Promise { const cronSecret = request.headers.get('x-cron-secret') if (!cronSecret || cronSecret !== process.env.CRON_SECRET) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } try { const result = await sendDueFinalDocReminders(prisma) return NextResponse.json({ ok: true, ...result }) } catch (error) { console.error('[Cron] final-document-reminders failed:', error) return NextResponse.json({ error: 'Internal error' }, { status: 500 }) } } ``` - [ ] **Step 5: Run to verify it passes** Run: `npx vitest run tests/unit/final-documents.test.ts -t 'sendDueFinalDocReminders'` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add src/server/services/final-documents.ts "src/app/api/cron/final-document-reminders/route.ts" tests/unit/final-documents.test.ts git commit -m "feat(final-docs): auto pre-deadline reminder cron" ``` --- ### Task 14: On-upload mentor notification **Files:** - Modify: `src/server/routers/applicant.ts` (`saveFileMetadata`) - Test: `tests/unit/final-documents.test.ts` - [ ] **Step 1: Write the failing test** ```ts // tests/unit/final-documents.test.ts — add: describe('saveFileMetadata → GRAND_FINAL_DOCS_SUBMITTED', () => { const localPrograms: string[] = [] const localUsers: string[] = [] afterAll(async () => { for (const id of localPrograms) await cleanupTestData(id, localUsers) }) it('notifies the mentor when a finalist uploads a LIVE_FINAL document', 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 }) const req = 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 mentor = await createTestUser('MENTOR'); localUsers.push(mentor.id) await prisma.mentorAssignment.create({ data: { projectId: project.id, mentorId: mentor.id } }) const caller = createCaller(applicantRouter.applicantRouter, lead) await caller.saveFileMetadata({ projectId: project.id, fileName: 'exec.pdf', mimeType: 'application/pdf', size: 100, fileType: 'EXEC_SUMMARY', bucket: 'b', objectKey: uid('key'), roundId: round.id, requirementId: req.id, }) const notif = await prisma.inAppNotification.findFirst({ where: { userId: mentor.id, type: 'GRAND_FINAL_DOCS_SUBMITTED' } }) expect(notif).not.toBeNull() }) }) ``` > Confirm `saveFileMetadata`'s exact input schema (read `src/server/routers/applicant.ts:363+`) and match the test's argument keys exactly (it accepts `requirementId`/`roundId`). - [ ] **Step 2: Run to verify it fails** Run: `npx vitest run tests/unit/final-documents.test.ts -t 'GRAND_FINAL_DOCS_SUBMITTED'` Expected: FAIL — no notification created. - [ ] **Step 3: Add the hook** In `src/server/routers/applicant.ts`, ensure `notifyProjectMentors` and `NotificationTypes` are imported (add to the existing in-app-notification import). After the `projectFile.create(...)` succeeds inside `saveFileMetadata`, add a best-effort block: ```ts // Notify the team's mentor(s) when a Grand-Final document is uploaded. if (input.roundId) { try { const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { roundType: true } }) if (round?.roundType === 'LIVE_FINAL') { await notifyProjectMentors(input.projectId, { type: NotificationTypes.GRAND_FINAL_DOCS_SUBMITTED, title: 'Final document uploaded', message: `A team uploaded a Grand Final document: ${input.fileName}`, linkUrl: `/mentor/workspace/${input.projectId}`, metadata: { projectId: input.projectId, fileName: input.fileName }, }) } } catch (e) { console.error('[final-docs] mentor notify failed', e) } } ``` - [ ] **Step 4: Run to verify it passes** Run: `npx vitest run tests/unit/final-documents.test.ts -t 'GRAND_FINAL_DOCS_SUBMITTED'` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/server/routers/applicant.ts tests/unit/final-documents.test.ts git commit -m "feat(final-docs): notify mentor when a finalist uploads a Grand Final document" ``` **Phase 4 checkpoint:** `npm run build` + full `npx vitest run` green. --- ## Final verification (before deploy) - [ ] `npm run typecheck` — clean. - [ ] `npm run build` — clean. - [ ] `npx vitest run` — all green (existing suite + new `final-documents.test.ts`). - [ ] Live-UI dev smoke (use a real finalist + admin + a finale-jury-group member): - Banner renders for a finalist with the correct counts + deadline; hides for non-finalists. - `/applicant/documents` shows the 4 requirements; upload still works. - Team mentor page + mentor workspace show the Final Documents panel. - `/jury/finals-documents` lists all teams for an admin and a finale-jury member; 403s a non-finale juror. - Admin "Remind teams" button fires; an in-app + email notification appears. - Manually hit `GET /api/cron/final-document-reminders` with the `x-cron-secret` header → `{ ok: true, remindersSent: N }`. ## Deployment (per the runbook + memories) 1. Reconfigure prod requirements: copy `scripts/configure-grand-final-requirements.mjs` into the prod app container and run `node scripts/configure-grand-final-requirements.mjs` (dry-run) then `--apply` via `docker exec -i mopc-app node ...`, OR run the equivalent guarded inline script. Back up first per `reference_prod_db_ops` if writing. 2. Commit all phases on `grand-final-documents`, push to `code.monaco-opc.com/MOPC/MOPC-Portal`. 3. **Track the Gitea CI build** until it publishes `mopc/mopc-portal:latest`. 4. Redeploy: `ssh stefan@89.58.5.223:22022`, `cd /opt/letsbe/stacks/mopc-portal`, `docker compose pull && docker compose up -d` (**never** `-v`). 5. Verify on prod: migration `add_final_docs_reminder_sent_at` applied; `seed-notification-settings` ran (40+2 rows incl. the two new types); `GET /api/cron/final-document-reminders` (with secret) returns ok. 6. Register the new cron (`/api/cron/final-document-reminders`) in whatever schedules the existing crons (mirror `finalist-confirmations`). 7. Admin actions: populate the "Finals Jury" group; extend the Grand Final `windowCloseAt` to the real deadline. ## Self-review notes (addressed) - **Spec coverage:** banner (Task 3), mentor surfacing both sides (Tasks 10–11), judge page (Tasks 8–9), notifications auto+manual (Tasks 4–6, 13), on-upload mentor notif (Task 14), migration (Task 12), 4-doc reconfig (Task 7), deadline/timezone (banner/panel components), required=true (Task 7). Admin override (`adminConfirm`/`unenroll`) already exists — no task needed. - **Type consistency:** `getFinalDocumentStatusForProject` / `FinalDocumentStatus` / `sendManualFinalDocReminders` / `listFinalistDocumentsForReview` / `userCanReviewFinals` / `sendDueFinalDocReminders` names are used identically across tasks. - **Known verification points flagged inline:** `EmailPreviewDialog` prop names, `getPresignedUrl` GET-form export name, `FilePreview` props, `saveFileMetadata` input schema, the `(jury)` layout admin access, the active-program query for the judge page, `logAudit` call shape, `FinalistConfirmation` required fields in tests.