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

71 KiB
Raw Blame History

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 FileRequirementProjectFile 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.tsgetFinalDocumentStatus query; on-upload hook in saveFileMetadata.
  • src/server/routers/finalist.tslistReviewDocuments, sendDocumentReminders.
  • src/server/routers/mentor.tsgetProjectFinalDocuments.
  • 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

// 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
// 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
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)

// 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):

import { getFinalDocumentStatusForProject } from '../services/final-documents'

Add this procedure inside the applicantRouter object (e.g. right after getMyDashboard):

  /** 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
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

// 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:

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:

        <FinalDocumentsBanner />
  • Step 3: Typecheck + build

Run: npm run typecheck Expected: no errors.

  • Step 4: Commit
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:

  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):

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):

  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):

  {
    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
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

// 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:

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:

import { sendManualFinalDocReminders } from '../services/final-documents'

Add the procedure inside finalistRouter:

  /** 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
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

// 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:

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

// 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
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)

// 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)):

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:

import { sendManualFinalDocReminders, listFinalistDocumentsForReview, userCanReviewFinals } from '../services/final-documents'

Add the procedure (uses protectedProcedure since finale jurors must reach it — auth is done inside):

  /** 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
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
// 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
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

// 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:

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):

  /** 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
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)

// 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:

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):

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
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:

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

// 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:

/**
 * 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
// 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
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

// 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:

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