Files
MOPC-Portal/docs/superpowers/plans/2026-06-09-grand-final-documents.md
Matt b757aae551 docs(grand-final): implementation plan (4 phases, TDD)
Phase 1: foundation service + finalist banner + manual reminder + 4-doc reconfig
Phase 2: judge review page (finale-access gate, embedded presigned URLs)
Phase 3: mentor-section Final Documents panel (team + mentor)
Phase 4: auto reminder cron + on-upload mentor notification + migration

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:57:19 +02:00

1647 lines
71 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<FinalDocumentStatus | null> {
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<string, (typeof files)[number]>()
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 (
<Card className={done ? 'border-emerald-200 bg-emerald-50/50' : 'border-brand-blue/30 bg-brand-blue/5'}>
<CardContent className="py-4 space-y-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
{done ? <CheckCircle2 className="h-5 w-5 text-emerald-600" /> : <Upload className="h-5 w-5 text-brand-blue" />}
<span className="font-semibold">
{done ? 'Grand Final documents submitted' : 'Upload your Grand Final documents'}
</span>
<span className="text-sm text-muted-foreground">({uploadedCount} of {total})</span>
</div>
{status.deadline && (
<span className={`flex items-center gap-1.5 text-sm ${status.deadlinePassed ? 'text-destructive' : 'text-muted-foreground'}`}>
<Clock className="h-4 w-4" />
{status.deadlinePassed ? 'Deadline passed' : 'Due'}: {fmt.format(new Date(status.deadline))}
{zone ? ` (${zone})` : ''}
</span>
)}
</div>
<div className="flex flex-wrap gap-3">
{status.requirements.map((r) => {
const Icon = r.acceptedMimeTypes.some((m) => m.startsWith('video/')) ? Video : FileText
return (
<span key={r.id} className="flex items-center gap-1.5 text-sm">
{r.uploaded ? <CheckCircle2 className="h-4 w-4 text-emerald-600" /> : <Circle className="h-4 w-4 text-muted-foreground/50" />}
<Icon className="h-4 w-4 text-muted-foreground" />
{r.name}
</span>
)
})}
</div>
{!done && (
<Button asChild size="sm" className="bg-brand-blue hover:bg-brand-blue-light">
<Link href="/applicant/documents">
<Upload className="mr-2 h-4 w-4" /> Upload documents
</Link>
</Button>
)}
</CardContent>
</Card>
)
}
```
- [ ] **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 `<AnimatedCard index={0}>` in the main column:
```tsx
<FinalDocumentsBanner />
```
- [ ] **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: <strong>${escapeHtml(missing.join(', '))}</strong>.`
: 'Please make sure all required documents are uploaded.'
const content = `
${sectionTitle('Grand Final — Final Documents')}
${paragraph(greeting)}
${paragraph(`Please upload the final documents for <strong>${escapeHtml(projectTitle)}</strong> ahead of the Monaco Ocean Protection Challenge Grand Finale.`)}
${paragraph(missingLine)}
${formattedDeadline ? infoBox(`Deadline: <strong>${escapeHtml(formattedDeadline)} (Paris time)</strong>.`, '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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm"><Mail className="mr-2 h-4 w-4" /> Remind teams to upload final documents</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Remind finalist teams</DialogTitle>
<DialogDescription>
Sends an in-app + email reminder to every finalist team with missing required documents.
</DialogDescription>
</DialogHeader>
<EmailPreviewDialog notificationType="GRAND_FINAL_DOCS_REMINDER" />
<DialogFooter>
<Button onClick={() => send.mutate({ programId })} disabled={send.isPending}>
<Send className="mr-2 h-4 w-4" /> {send.isPending ? 'Sending…' : 'Send reminders'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
```
> **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'
// ...
<FinalDocsReminderButton programId={programId} />
```
- [ ] **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 <overview-page-file>
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<ReviewPayload> {
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<string, (typeof files)[number]>()
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<boolean> {
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 (
<Card><CardContent className="flex flex-col items-center py-12 text-center">
<ShieldAlert className="h-10 w-10 text-muted-foreground/50 mb-3" />
<p className="font-medium">No access</p>
<p className="text-sm text-muted-foreground">This review is for the Grand-Final jury and program admins.</p>
</CardContent></Card>
)
}
if (isLoading || !data) return <div className="space-y-4"><Skeleton className="h-8 w-64" /><Skeleton className="h-64" /></div>
const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'short' })
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">Finalist Documents</h1>
<p className="text-muted-foreground mt-1">
{data.submittedCount} of {data.totalCount} teams complete
{data.round.deadline ? ` · due ${fmt.format(new Date(data.round.deadline))}` : ''}
</p>
</div>
{data.teams.map((team) => (
<Card key={team.projectId}>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg">{team.teamName}</CardTitle>
<div className="flex items-center gap-2">
{team.category && <Badge variant="secondary">{team.category}</Badge>}
<Badge variant={team.submitted ? 'default' : 'outline'} className={team.submitted ? 'bg-emerald-50 text-emerald-700 border-emerald-200' : ''}>
{team.submitted ? 'Complete' : 'Incomplete'}
</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
{team.documents.map((doc) => (
<div key={doc.requirementId} className="rounded-lg border p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium text-sm flex items-center gap-2"><FileText className="h-4 w-4" /> {doc.requirementName}</span>
{doc.file && (
<Button asChild variant="ghost" size="sm" className="h-7 px-2 text-xs">
<a href={doc.file.url} target="_blank" rel="noreferrer"><Download className="h-3 w-3 mr-1" /> Open</a>
</Button>
)}
</div>
{doc.file ? (
<FilePreview file={{ mimeType: doc.file.mimeType, fileName: doc.file.fileName }} url={doc.file.url} />
) : (
<p className="text-sm text-muted-foreground py-4 text-center">Not yet uploaded</p>
)}
</div>
))}
</CardContent>
</Card>
))}
</div>
)
}
```
> **Implementation note:** verify `FilePreview`'s exact prop shape (from `src/components/shared/file-viewer.tsx`) — the applicant documents page calls it as `<FilePreview file={{ mimeType, fileName }} url={...} />`, 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" <nav-file> <admin-overview-file>
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 (
<Card>
<CardHeader>
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="flex items-center gap-2 text-lg"><FileCheck2 className="h-5 w-5" /> Final Documents</CardTitle>
{status.allRequiredUploaded
? <Badge className="bg-emerald-50 text-emerald-700 border-emerald-200">Submitted</Badge>
: status.deadline && (
<span className={`flex items-center gap-1.5 text-sm ${status.deadlinePassed ? 'text-destructive' : 'text-muted-foreground'}`}>
<Clock className="h-4 w-4" /> Due {fmt.format(new Date(status.deadline))}
</span>
)}
</div>
<CardDescription>
{props.variant === 'team' ? 'Your final deliverables for the Grand Finale.' : 'This team\'s final deliverables for the Grand Finale.'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{status.requirements.map((r) => (
<div key={r.id} className="flex items-center justify-between rounded-lg border p-3">
<span className="flex items-center gap-2 text-sm">
{r.uploaded ? <CheckCircle2 className="h-4 w-4 text-emerald-600" /> : <Circle className="h-4 w-4 text-muted-foreground/50" />}
{r.name}
</span>
<span className="text-xs text-muted-foreground truncate max-w-[50%]">{r.file?.fileName ?? 'Not yet uploaded'}</span>
</div>
))}
{props.variant === 'team' && !status.allRequiredUploaded && (
<Button asChild size="sm" className="mt-2 bg-brand-blue hover:bg-brand-blue-light">
<Link href="/applicant/documents"><Upload className="mr-2 h-4 w-4" /> Upload documents</Link>
</Button>
)}
</CardContent>
</Card>
)
}
```
> 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:
<FinalDocumentsPanel variant="team" />
```
- [ ] **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 `<Tabs>` block (or as a new section above the tabs):
```tsx
import { FinalDocumentsPanel } from '@/components/applicant/final-documents-panel'
// ... below the Tabs:
<FinalDocumentsPanel variant="mentor" projectId={projectId} />
```
- [ ] **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 <name>`, 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<NextResponse> {
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 1011), judge page (Tasks 89), notifications auto+manual (Tasks 46, 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.