diff --git a/docs/superpowers/plans/2026-06-09-grand-final-documents.md b/docs/superpowers/plans/2026-06-09-grand-final-documents.md new file mode 100644 index 0000000..bc35749 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-grand-final-documents.md @@ -0,0 +1,1646 @@ +# 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.