# 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 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.
