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>
1647 lines
71 KiB
Markdown
1647 lines
71 KiB
Markdown
# 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.
|