Compare commits

...

21 Commits

Author SHA1 Message Date
Matt
696d7e9041 fix(final-docs): exclude dropped mentors from doc access; relative in-app reminder link
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m43s
Review follow-ups: mentor.getProjectFinalDocuments now filters droppedAt:null
(a dropped mentor no longer sees a team's final docs); reminder linkUrl is
relative so the in-app notification does client-side nav (email still absolutized).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:19:16 +02:00
Matt
f61dcfa89a feat(final-docs): notify mentor when a finalist uploads a Grand Final document
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:07:28 +02:00
Matt
146691be00 fix(auth): allow /api/cron paths past middleware (self-guarded by CRON_SECRET)
The middleware matcher intercepts /api/cron/* but the prefix was absent from
publicPaths, so unauthenticated scheduler calls were 307'd to /login and the
cron handlers never ran. All 9 cron routes already enforce x-cron-secret, so
opening the prefix is safe and unblocks the new final-document-reminders cron
(and repairs the existing crons). Same class of gap as the /lunch/pick fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:03:55 +02:00
Matt
e4f13aaed4 feat(final-docs): auto pre-deadline reminder cron 2026-06-09 16:00:42 +02:00
Matt
6e1dcc8cbf feat(final-docs): add FinalistConfirmation.finalDocsReminderSentAt
Additive nullable column for the auto document-reminder cron (fires once per
team). Migration hand-authored + applied to dev via db execute (NOT migrate dev,
per drifted-dev-history rule); prod applies it via migrate deploy on next build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:56:46 +02:00
Matt
24c7c4bc6c feat(final-docs): Final Documents panel on team + mentor views
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:53:39 +02:00
Matt
8c6a59bad9 feat(final-docs): mentor.getProjectFinalDocuments procedure
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:50:42 +02:00
Matt
b66e2071f9 refactor(final-docs): shared review component reachable by jury + admin routes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:47:56 +02:00
Matt
df0be6bb48 feat(final-docs): judge review page + entry points
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:44:37 +02:00
Matt
e9e072dda7 feat(final-docs): finalist document review service + procedure with finale-access gate
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:36:59 +02:00
Matt
b0a0a71cfe chore(final-docs): script to configure the 4 grand-final file requirements 2026-06-09 15:33:25 +02:00
Matt
61bf5a4032 feat(final-docs): admin manual reminder button
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:31:18 +02:00
Matt
26709e2c9b feat(final-docs): manual admin document-reminder blast
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:26:50 +02:00
Matt
f3d3a21156 feat(final-docs): GRAND_FINAL_DOCS_REMINDER/SUBMITTED notification types + email template
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:23:44 +02:00
Matt
9e058e6ad7 feat(final-docs): finalist upload banner on applicant dashboard 2026-06-09 15:21:08 +02:00
Matt
16e0a08f16 feat(final-docs): applicant.getFinalDocumentStatus procedure 2026-06-09 15:17:47 +02:00
Matt
c53ec23109 fix(final-docs): round-scope file query + guard empty-required edge case 2026-06-09 15:15:22 +02:00
Matt
b1923cf0e1 feat(final-docs): grand-final document status service 2026-06-09 15:09:50 +02:00
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
Matt
c2afa58606 docs(grand-final): 4-doc set (PDF-only), thin dedicated judge page rationale
Confirmed document set: Final Presentation, Final Business Plan, 1-min Video,
Executive Summary (all required, PDF-only docs), same for both categories.
Judge page stays a thin dedicated page reusing the existing doc viewer because
the finale has 0 assignments / empty jury group (group-based, not assignment-based).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:49:26 +02:00
Matt
537de07245 docs(grand-final): spec for final-document upload, mentor surfacing, judge review, notifications
Design for surfacing the already-live Grand Final document upload (PDF + 1-min
video) to the 9 confirmed finalists via a dashboard banner, a read-only judge
review page (Finals Jury group + admins), a Final Documents panel on both the
team and mentor views, and email + in-app reminders (auto cron + manual blast).
Reuses the existing legacy FileRequirement anchor, upload mechanics, and
notification pipeline. Grounded in verified prod state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:31:49 +02:00
28 changed files with 2980 additions and 2 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,156 @@
# Grand-Final Documents — upload visibility, mentor surfacing, judge review, notifications
**Date:** 2026-06-09
**Status:** Design — pending review
**Edition:** MOPC 2026 (program "Monaco Ocean Protection Challenge", competition `MOPC 2026`)
## Problem
Nine finalist teams must submit two final deliverables ahead of the Grand Final — a **final PDF presentation** and a **~1-minute video** — and the upcoming Grand-Final judges need to review those documents. Today there is no discoverable upload prompt, no consolidated judge review surface, and no notifications driving teams to upload.
## Current state (verified against prod, 2026-06-09)
The data layer already exists and is correctly set up:
- **"Grand Final" (LIVE_FINAL) round is `ROUND_ACTIVE`** with `windowCloseAt = 2026-06-11 21:00 UTC` and the empty **"Finals Jury"** group attached (`juryGroupId` set, **0 members**).
- **Two `FileRequirement` rows already exist on the Grand Final round** (legacy per-round system): **"PDF presentation support"** (`application/pdf`) and **"1 minute video"** (`video/*`). Both currently `required = false`, `maxSizeMB = null`, **0 files uploaded**. This set is being expanded to the confirmed 4-document set below.
- **All 9 finalist teams are correctly enrolled**: each has a `ProjectRoundState` in both the (closed) Mentoring round and the (active) Grand Final round, and all 9 have `FinalistConfirmation.status = CONFIRMED`. No mismatches (confirmed↔enrolled↔in-mentor all aligned). All 9 share one mentor, **Camille Lopez**. Attendee counts 14 (program `defaultAttendeeCap = 4`).
- Auto-enroll (confirmed + in mentor round → Grand Final round) is working via `finalist.enrollFinalists`; the **admin override already exists** (`finalist.adminConfirm` to mark attending without a token; `finalist.unenroll` to remove) in the `/admin/logistics` **Confirmations tab** + attendance dialog.
### What this means
The **upload already works today**: `/applicant/documents` renders an upload section for every round in `openRounds`, where `openRounds` = program rounds that are `ROUND_ACTIVE` **and** the project is a member of (`applicant.getMyDashboard`). The Grand Final round qualifies, so all 9 teams can upload the PDF + video right now. The upload procedure (`applicant.getUploadUrl`) permits any team member to upload against a requirement as long as the round is `ROUND_ACTIVE` (it is). The `windowCloseAt` deadline is **advisory only** (display, not blocking).
The gaps are therefore: **discoverability** (no banner/notification), **judge review** (no consolidated surface; jury can only see files for projects they are individually assigned to, and the Finals Jury group is empty), and **mentor-section surfacing** (the final documents never appear in the mentor area).
## Goals
1. Make the existing finalist upload **discoverable** via a dashboard banner and notifications.
2. Give the upcoming Grand-Final judges a **read-only review page** of all finalists' documents.
3. Surface the final documents (and a pre-deadline cue) inside **the mentor section**, on both the team's and the mentor's views.
4. Add **email + in-app notifications**, triggered **automatically** (pre-deadline reminder cron) and **manually** (admin blast).
## Document set (confirmed)
The Grand Final round's `FileRequirement` rows are reconfigured to **four required documents**, identical for both categories (STARTUP and BUSINESS_CONCEPT) — the per-round `FileRequirement` model already applies one set to all teams in the round:
1. **Final Presentation**`application/pdf` (rename of the existing "PDF presentation support" row)
2. **Final Business Plan**`application/pdf` (new)
3. **1-minute Video**`video/*` (existing "1 minute video" row)
4. **Executive Summary**`application/pdf` (new)
All four `required = true`. PDF-only for the three document slots (no Word). This is an additive/safe prod data change (0 files currently uploaded). If per-category document sets are ever needed, that is out of scope here (the per-round model does not support it without extra work).
## Non-goals (YAGNI)
- Admin approve / needs-changes review workflow on documents.
- Migrating to the heavier `SubmissionWindow` system (the legacy `FileRequirement` anchor is already set up and proven).
- A mentor-milestone tracker UI (`MentorMilestone`/`MentorMilestoneCompletion` models exist but have no UI; "last steps of the mentor round" is treated as descriptive framing, surfaced as a read-only Final Documents panel, not a milestone system).
- Comments/threads on the judge review page.
- New admin enrollment/override controls (`adminConfirm` + `unenroll` already exist; we only ensure they are reachable from the finale round overview).
## Architecture decision
Build thin additions on the **existing legacy `FileRequirement` → `ProjectFile` anchor** that is already configured on the Grand Final round. Reuse:
- Upload mechanics (`applicant.getUploadUrl` / `saveFileMetadata`, presigned MinIO PUT, `RequirementUploadList`).
- File preview/download (`FilePreview` / `file-viewer`, `file.getDownloadUrl`).
- The notification pipeline (`createNotification`, `notifyProjectTeam`/`notifyProjectMentors`, `NotificationEmailSetting`, `NOTIFICATION_EMAIL_TEMPLATES`, `sendStyledNotificationEmail`) and the reminder-cron pattern (`sendDueConfirmationReminders`).
**The deadline everywhere** (banner, mentor cue, reminder cron) is the Grand Final round's single `windowCloseAt` field, edited by admins in round settings. All deadline displays use **browser-local time + zone label** (`Intl.DateTimeFormat`), never UTC/fixed Monaco time, per the locked grand-finale timezone rule. Deadline behavior is **soft/advisory** — uploads stay open while the round is active; past the date, files are flagged "late" in the UI but still accepted.
## Shared service: `src/server/services/final-documents.ts`
A new service centralizes the logic, wrapped by thin tRPC procedures:
- `getFinalDocumentStatusForProject(prisma, projectId)``{ roundId, roundName, deadline, deadlinePassed, requirements: [{ id, name, acceptedMimeTypes, uploaded, file? }], allRequiredUploaded }` or `null` when the project is not a CONFIRMED finalist in an active LIVE_FINAL round. The single source of truth for the banner, the mentor panels, and reminder targeting.
- `listFinalistDocumentsForReview(prisma, programId)``{ round: { name, deadline }, totalCount, submittedCount, teams: [{ projectId, teamName, category, confirmStatus, documents: [{ requirementId, requirementName, file? }] }] }`. File metadata only; presigned URLs are fetched per-file on demand by the client via existing `file.getDownloadUrl`.
- `sendDueFinalDocReminders(prisma)` → cron entry. Targets CONFIRMED finalists in the active LIVE_FINAL round with at least one required document missing, whose `finalDocsReminderSentAt` is null and whose deadline is within the reminder window; creates `GRAND_FINAL_DOCS_REMINDER` notifications and stamps `finalDocsReminderSentAt`. Best-effort per row.
- `sendManualFinalDocReminders(prisma, { programId, projectIds?, actorId })` → admin blast. For the given projects (default: all CONFIRMED finalists with missing required docs), create `GRAND_FINAL_DOCS_REMINDER` notifications regardless of `finalDocsReminderSentAt`. Returns `{ sent }`.
## Components
### 1. Finalist upload banner (applicant dashboard)
- New auto-hiding banner component (pattern of `LunchBanner`/`MyLogisticsCard`: returns `null` when not applicable) on `src/app/(applicant)/applicant/page.tsx`.
- Backed by a new query **`applicant.getFinalDocumentStatus`** (wraps `getFinalDocumentStatusForProject` for the caller's project).
- Shows: heading ("Upload your Grand Final documents"), each required document with a ✓ / empty state (e.g. "2 of 4 uploaded"), deadline in browser-local time + zone, and a CTA button → `/applicant/documents`. Collapses to a "✓ Submitted" confirmation once all required documents are uploaded. Non-dismissible while incomplete.
### 2. Mentor-section "Final Documents" panel (team + mentor)
A new read-only `FinalDocumentsPanel` component rendered on **both** mentor surfaces:
- **Team view** — `src/app/(applicant)/applicant/mentor/page.tsx` (adds a panel below the existing mentor cards / chat / workspace-files). Uses `applicant.getFinalDocumentStatus`.
- **Mentor view** — `src/app/(mentor)/mentor/workspace/[projectId]/page.tsx` (adds a "Final Documents" section or tab for the viewed project). Uses a new **`mentor.getProjectFinalDocuments`** procedure (mentor/team access check) wrapping `getFinalDocumentStatusForProject`.
Behavior: before upload + as the deadline nears, a **visual cue** ("Final grand-final documents due [date] — upload now", with an upload CTA on the team view); after upload, the PDF + video appear as the team's read-only "final documents" (inline preview / video player / download), visible to both the team and their mentor. Same underlying `ProjectFile`s — no duplicate storage.
### 3. Judge review page (thin dedicated page, reusing existing components)
**Why dedicated rather than baking into the existing per-project jury page** (verified in prod): the finale has **0 `Assignment` rows**, the "Finals Jury" group has **0 members**, and no `LiveVotingSession` exists. The existing jury flow is assignment-gated — the round page lists `roundAssignment.getMyAssignments` (empty for the finale) and `file.listByProject` 403s any juror without an `Assignment` to the project. The finale runs on a **group + live-session** model, not per-project assignments. Baking in would require either fabricating an assignment per judge × finalist or rewiring the assignment-based access path — more work and risk, and a worse UX (one project at a time vs. all finalists at once). So a dedicated page is the better fit *because* the existing page's access model does not apply to the finale.
It stays thin by **reusing existing components** — the same `MultiWindowDocViewer` / `FilePreview` / `<video>` / `file.getDownloadUrl` used on the per-project page — laid out as a consolidated finale list. Not a reinvented viewer.
- New read-only page (e.g. `src/app/(jury)/jury/finals-documents/page.tsx`) listing all finalist teams grouped by category, each with its four documents (3 PDFs + video) via the reused viewer/preview components plus download. Missing documents show "Not yet uploaded". Header shows the deadline and "X of N submitted".
- Backed by a new **`finalist.listReviewDocuments`** procedure (wraps `listFinalistDocumentsForReview`). Authorization: **SUPER_ADMIN / PROGRAM_ADMIN, OR a `JuryGroupMember` of the active LIVE_FINAL round's jury group**, via a new `assertFinalsReviewAccess(ctx)` helper. Unauthorized → access-denied state.
- Entry points: a jury sidebar link ("Finalist Documents") shown when an active LIVE_FINAL round exists, and a "Review finalist documents" link on the admin Grand Final round overview.
- **Implementation note:** verify the `(jury)` route-group layout does not hard-redirect admins; if it does, either relax it for this page or mount the page on a neutral path reachable by both. Authorization is enforced by the procedure regardless.
### 4. Notifications (email + in-app; auto + manual)
- **New notification type `GRAND_FINAL_DOCS_REMINDER`** (team-facing): added to `NotificationTypes`, with a `NOTIFICATION_EMAIL_TEMPLATES` entry (branded) and a `seed-notification-settings.ts` row (`category: "logistics"`, per-type `sendEmail` toggle, subject/body overridable). In-app + email.
- **New notification type `GRAND_FINAL_DOCS_SUBMITTED`** (mentor-facing, light): when a team uploads a final document, notify the team's mentor(s) in-app so it surfaces in their mentor section. Seed row with `sendEmail` default **off** (in-app on). Triggered from `applicant.saveFileMetadata` when the file is for the LIVE_FINAL round (best-effort, never throws).
- **Automatic (cron):** new route `src/app/api/cron/final-document-reminders/route.ts` (protected by `CRON_SECRET`) calling `sendDueFinalDocReminders`. Reminder window read from the round `configJson.finalDocsReminderHoursBeforeDeadline` (default 48h). Fires once per team (stamped via `finalDocsReminderSentAt`). This doubles as the initial "documents are open" nudge.
- **Manual (admin):** a "Remind teams to upload final documents" action with a live `EmailPreviewDialog` (mirrors the existing finalist reminder-blast), backed by `finalist.sendDocumentReminders``sendManualFinalDocReminders`. Placed on the admin Grand Final round overview and/or the `/admin/logistics` Confirmations tab. Usable immediately to kick off the round.
### 5. Minor polish
- Reconfigure the round's `FileRequirement` rows to the 4-document set (rename "PDF presentation support" → "Final Presentation"; add "Final Business Plan" + "Executive Summary" as `application/pdf`; keep "1-minute Video"), all `required = true` (guarded prod data update at ship time, or via the admin round file-requirement editor if present). Additive/safe — 0 files uploaded.
- Confirm admins can edit the round's `windowCloseAt` in round settings (the admin-set deadline). If no input exists, add a small one; likely already present.
## Data model changes
- `FinalistConfirmation.finalDocsReminderSentAt DateTime?` (new nullable column) — lets the auto reminder fire once per team. Migration required (additive, safe).
- `NotificationTypes`: add `GRAND_FINAL_DOCS_REMINDER`, `GRAND_FINAL_DOCS_SUBMITTED`.
- `seed-notification-settings.ts`: add rows for both new types (auto-provisions on deploy).
- Optional round config field `finalDocsReminderHoursBeforeDeadline` (default 48) validated in the LIVE_FINAL round Zod config.
## tRPC procedures (new)
| Procedure | Router | Auth | Purpose |
|---|---|---|---|
| `applicant.getFinalDocumentStatus` | applicant | protected (team member) | Banner + team mentor panel |
| `mentor.getProjectFinalDocuments` | mentor | mentor/team access to project | Mentor workspace panel |
| `finalist.listReviewDocuments` | finalist | admin OR finale jury-group member | Judge review page |
| `finalist.sendDocumentReminders` | finalist | admin | Manual reminder blast |
(Reminder email preview reuses the existing `notification.previewEmailTemplate`.)
## Testing
Vitest (sequential, factories per `tests/helpers.ts`):
- `getFinalDocumentStatusForProject`: all 4 required uploaded / partial / none (`allRequiredUploaded` correct); returns `null` for a non-confirmed team and when no active LIVE_FINAL round; `deadlinePassed` reflects `windowCloseAt`.
- `listFinalistDocumentsForReview`: returns all finalist teams with correct per-requirement file mapping and `submittedCount`.
- Authorization matrix for `finalist.listReviewDocuments`: admin ✓, finale jury-group member ✓, non-finale jury member ✗, applicant ✗.
- `sendDueFinalDocReminders`: targets only CONFIRMED finalists with missing required docs inside the window; stamps `finalDocsReminderSentAt`; idempotent (no double-send).
- `finalist.sendDocumentReminders`: admin only; counts correctly.
Live-UI smoke on dev (lesson learned — catches what tests/build miss): banner renders for a finalist; team mentor panel + mentor-workspace panel render; judge page renders for an admin and for a finale jury-group member and denies a non-finale juror; upload still works; a manual reminder produces an in-app + email notification.
## Prerequisites / admin actions (outside code)
1. **Populate the "Finals Jury" group** with the actual judges (existing jury-group admin UI) — required before the review page is useful to them.
2. **Extend the Grand Final `windowCloseAt`** (currently 2026-06-11, ~2 days out) to the intended deadline.
3. Reconfigure the round's file requirements to the 4-document set (Final Presentation, Final Business Plan, 1-minute Video, Executive Summary), all `required = true` — I can do this as a guarded prod update.
## Build sequence (shippable in phases; deadline is imminent)
1. **Banner + manual admin reminder + minor polish** — makes the existing upload discoverable now (most urgent).
2. **Judge review page** + access helper + entry points.
3. **Mentor-section Final Documents panel** (team + mentor) + `mentor.getProjectFinalDocuments`.
4. **Auto reminder cron** + `GRAND_FINAL_DOCS_SUBMITTED` on-upload mentor notification + new notification types/templates/seed + migration.
## Deployment
Per the prod-deploy runbook: after everything is reviewed and tested (build clean, `npx vitest run` green), commit to `main`, push to `code.monaco-opc.com/MOPC/MOPC-Portal`, **track the Gitea CI build** until it publishes `mopc/mopc-portal:latest`, then redeploy on prod (`ssh stefan@89.58.5.223:22022`, `/opt/letsbe/stacks/mopc-portal`): `docker compose pull && docker compose up -d` (NEVER `-v`). Confirm the additive migration applied and the new notification-settings rows seeded, then live-smoke the banner, judge page, and a manual reminder.

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "FinalistConfirmation" ADD COLUMN "finalDocsReminderSentAt" TIMESTAMP(3);

View File

@@ -2762,6 +2762,7 @@ model FinalistConfirmation {
declineReason String? // optional free-text on decline
expiredAt DateTime?
reminderSentAt DateTime? // set when the pre-deadline reminder is sent (cron)
finalDocsReminderSentAt DateTime? // set when the grand-final document-upload reminder is sent (cron)
promotedFromWaitlistEntryId String? @unique // null for original finalists
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -271,6 +271,20 @@ const NOTIFICATION_EMAIL_SETTINGS = [
description: 'Email to the attendee when their visa application status changes',
sendEmail: true,
},
{
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,
},
// Admin notifications (in-app only by default)
{

View File

@@ -0,0 +1,33 @@
// 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())

View File

@@ -0,0 +1,7 @@
import { FinalsDocumentsReview } from '@/components/finals/finals-documents-review'
export const dynamic = 'force-dynamic'
export default function AdminFinalsDocumentsPage() {
return <FinalsDocumentsReview />
}

View File

@@ -96,6 +96,7 @@ import { MentoringProjectsTable } from '@/components/admin/round/mentoring-proje
import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card'
import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card'
import { FinalistEnrollmentCard } from '@/components/admin/grand-finale/finalist-enrollment-card'
import { FinalDocsReminderButton } from '@/components/admin/grand-finale/final-docs-reminder-button'
import { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
@@ -1529,6 +1530,15 @@ export default function RoundDetailPage() {
{isGrandFinale && programId && (
<>
<FinalistEnrollmentCard programId={programId} roundId={roundId} />
<div className="flex justify-end gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/admin/finals-documents">
<FileText className="mr-2 h-4 w-4" />
Review finalist documents
</Link>
</Button>
<FinalDocsReminderButton programId={programId} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<FinalistSlotsCard programId={programId} />
<WaitlistCard programId={programId} />

View File

@@ -16,6 +16,7 @@ import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { MentorChat } from '@/components/shared/mentor-chat'
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
import { FinalDocumentsPanel } from '@/components/applicant/final-documents-panel'
import { RequestChangeDialog } from './request-change-dialog'
import {
MessageSquare,
@@ -216,6 +217,9 @@ export default function ApplicantMentorPage() {
/>
)}
{/* Final Documents (self-hides when not a finalist) */}
<FinalDocumentsPanel variant="team" />
{/* Request change dialog */}
{projectId && (
<RequestChangeDialog

View File

@@ -20,6 +20,7 @@ import { MentoringRequestCard } from '@/components/applicant/mentoring-request-c
import { MentorConversationCard } from '@/components/applicant/mentor-conversation-card'
import { AttendingMembersCard } from '@/components/applicant/attending-members-card'
import { MyLogisticsCard } from '@/components/applicant/my-logistics-card'
import { FinalDocumentsBanner } from '@/components/applicant/final-documents-banner'
import { LunchBanner } from '@/components/applicant/lunch-banner'
import { ExternalAttendeesStrip } from '@/components/applicant/external-attendees-strip'
import { AnimatedCard } from '@/components/shared/animated-container'
@@ -206,6 +207,9 @@ export default function ApplicantDashboardPage() {
</div>
</div>
{/* Grand Final document upload banner (auto-hides for non-finalists) */}
<FinalDocumentsBanner />
{/* Active round deadline banner */}
{!isRejected && openRounds.length > 0 && (() => {
const submissionTypes = new Set(['INTAKE', 'SUBMISSION', 'MENTORING'])

View File

@@ -0,0 +1,7 @@
import { FinalsDocumentsReview } from '@/components/finals/finals-documents-review'
export const dynamic = 'force-dynamic'
export default function FinalsDocumentsPage() {
return <FinalsDocumentsReview />
}

View File

@@ -17,6 +17,7 @@ import {
import { WorkspaceChat } from '@/components/mentor/workspace-chat'
import { FilePromotionPanel } from '@/components/mentor/file-promotion-panel'
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
import { FinalDocumentsPanel } from '@/components/applicant/final-documents-panel'
import { ArrowLeft, MessageSquare, FileText, Upload, Users } from 'lucide-react'
import { toast } from 'sonner'
@@ -186,6 +187,9 @@ export default function MentorWorkspaceDetailPage() {
)}
</TabsContent>
</Tabs>
{/* Final Documents (self-hides when not a finalist) */}
<FinalDocumentsPanel variant="mentor" projectId={projectId} />
</div>
)
}

View File

@@ -0,0 +1,17 @@
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 })
}
}

View File

@@ -0,0 +1,82 @@
'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/round/email-preview-dialog'
import { Eye, Mail, Send } from 'lucide-react'
import { toast } from 'sonner'
const REMINDER_TYPE = 'GRAND_FINAL_DOCS_REMINDER'
export function FinalDocsReminderButton({ programId }: { programId: string }) {
const [open, setOpen] = useState(false)
const [previewOpen, setPreviewOpen] = useState(false)
const preview = trpc.notification.previewEmailTemplate.useQuery(
{ notificationType: REMINDER_TYPE },
{ enabled: previewOpen },
)
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>
<DialogFooter className="sm:justify-between">
<Button variant="ghost" size="sm" onClick={() => setPreviewOpen(true)}>
<Eye className="mr-2 h-4 w-4" /> Preview email
</Button>
<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>
{/* Self-contained preview dialog — rendered as a sibling so it is not nested
inside the confirm dialog's content. */}
<EmailPreviewDialog
open={previewOpen}
onOpenChange={setPreviewOpen}
title="Final Documents Reminder"
description="Preview of the email finalist teams receive."
recipientCount={0}
previewHtml={preview.data?.html}
isPreviewLoading={preview.isLoading}
onSend={() => {}}
isSending={false}
previewOnly
showCustomMessage={false}
/>
</>
)
}

View File

@@ -0,0 +1,61 @@
'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>
)
}

View File

@@ -0,0 +1,59 @@
'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>
)
}

View File

@@ -0,0 +1,147 @@
'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'
/**
* Read-only judge review of finalist grand-final documents. Self-resolves the
* active program and self-gates on the `finalist.listReviewDocuments` FORBIDDEN
* check, so it can be mounted on any route reachable by either the finals jury
* (`/jury/finals-documents`) or program admins (`/admin/finals-documents`).
*/
export function FinalsDocumentsReview() {
const { data: programId, isLoading: programLoading } =
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>
)
}
// No active program resolved — nothing to review.
if (!programLoading && !programId) {
return (
<Card>
<CardContent className="flex flex-col items-center py-12 text-center">
<FileText className="h-10 w-10 text-muted-foreground/50 mb-3" />
<p className="font-medium">No active program</p>
<p className="text-sm text-muted-foreground">
Finalist documents will appear here once a program is active.
</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>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { BookOpen, Home, Trophy, ClipboardList } from 'lucide-react'
import { BookOpen, Home, Trophy, ClipboardList, FileText } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
@@ -61,6 +61,11 @@ export function JuryNav({ user }: JuryNavProps) {
href: '/jury/competitions',
icon: ClipboardList,
},
{
name: 'Finalist Documents',
href: '/jury/finals-documents',
icon: FileText,
},
...(myAwards && myAwards.length > 0
? [
{

View File

@@ -61,6 +61,7 @@ export const authConfig: NextAuthConfig = {
'/lunch/pick', // external attendees pick a dish via signed token (no account)
'/api/auth',
'/api/trpc', // tRPC handles its own auth via procedures
'/api/cron', // cron endpoints self-guard via x-cron-secret (CRON_SECRET)
]
// Check if it's a public path

View File

@@ -2238,6 +2238,42 @@ function getFinalistReminderTemplate(
}
}
/**
* Reminder email sent to a finalist team to upload their Grand Final documents.
*/
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 }
}
/**
* Notification email sent to a team when their confirmed finalist slot is withdrawn by an admin.
*/
@@ -2685,6 +2721,14 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
new Date((ctx.metadata?.deadline as string) || Date.now()),
ctx.linkUrl || '',
),
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[]) || [],
),
FINALIST_WITHDRAWN: (ctx) =>
getFinalistWithdrawnTemplate(
ctx.name || '',

View File

@@ -7,8 +7,9 @@ import { generateLogoKey, createStorageProvider, type StorageProviderType } from
import { getImageUploadUrl, confirmImageUpload, getImageUrl, deleteImage, type ImageUploadConfig } from '@/server/utils/image-upload'
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
import { logAudit } from '@/server/utils/audit'
import { createNotification } from '../services/in-app-notification'
import { createNotification, notifyProjectMentors, NotificationTypes } from '../services/in-app-notification'
import { checkRequirementsAndTransition, triggerInProgressOnActivity, transitionProject, isTerminalState } from '../services/round-engine'
import { getFinalDocumentStatusForProject } from '../services/final-documents'
import { EvaluationConfigSchema, MentoringConfigSchema } from '@/types/competition-configs'
import type { PrismaClient, Prisma, RoundType } from '@prisma/client'
@@ -498,6 +499,24 @@ export const applicantRouter = router({
console.warn('[DocAnalyzer] Post-upload analysis failed:', err))
).catch(() => {})
// 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)
}
}
return file
}),
@@ -1521,6 +1540,22 @@ export const applicantRouter = router({
}
}),
/** 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)
}),
/**
* Lightweight flags for conditional nav rendering.
*/

View File

@@ -54,6 +54,20 @@ export const competitionRouter = router({
return competition
}),
/**
* Resolve the id of the most-recent ACTIVE program for the logged-in user.
* Used by client pages (e.g. the finalist-documents judge review) that need a
* programId but don't have one in the route. Returns null when none is active.
*/
getActiveProgramId: protectedProcedure.query(async ({ ctx }) => {
const program = await ctx.prisma.program.findFirst({
where: { status: 'ACTIVE' },
orderBy: { year: 'desc' },
select: { id: true },
})
return program?.id ?? null
}),
/**
* Get competition by ID with rounds, jury groups, and submission windows
*/

View File

@@ -19,6 +19,7 @@ import {
resetOrCreatePendingConfirmation,
confirmAttendanceInTx,
} from '../services/finalist-enrollment'
import { sendManualFinalDocReminders, listFinalistDocumentsForReview, userCanReviewFinals } from '../services/final-documents'
export const finalistRouter = router({
/** List all per-category finalist slot quotas for a program. */
@@ -1660,4 +1661,33 @@ export const finalistRouter = router({
return { ok: true }
}),
/** 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({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'FINALIST_DOCS_REMINDER_SENT',
entityType: 'Program',
entityId: input.programId,
detailsJson: { sent: result.sent, projectIds: input.projectIds ?? 'all-missing' },
})
return result
}),
/** 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)
}),
})

View File

@@ -50,6 +50,7 @@ import {
signMentorUploadToken,
verifyMentorUploadToken,
} from '@/lib/mentor-upload-token'
import { getFinalDocumentStatusForProject } from '../services/final-documents'
/**
* True if the project is enrolled in a MENTORING round that is still
@@ -215,6 +216,21 @@ async function assertProjectWorkspaceAccess(
}
export const mentorRouter = router({
/** 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, droppedAt: null }, 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)
}),
/**
* Get AI-suggested mentor matches for a project
*/

View File

@@ -119,6 +119,11 @@ const NOTIFICATION_SAMPLE_DATA: Record<string, Record<string, unknown>> = {
FINALIST_EXPIRED: { projectTitle: 'Ocean Cleanup Initiative', category: 'STARTUP' },
FINALIST_WAITLIST_PROMOTED: { projectTitle: 'Reef Guardians', category: 'STARTUP' },
FINALIST_REMINDER: { projectTitle: 'Ocean Cleanup Initiative', deadline: new Date(Date.now() + 86_400_000).toISOString() },
GRAND_FINAL_DOCS_REMINDER: {
projectTitle: 'Ocean Cleanup Initiative',
deadline: new Date(Date.now() + 5 * 86_400_000).toISOString(),
missing: ['Pitch deck', 'Final video', 'Team photo'],
},
FINALIST_WITHDRAWN: { projectTitle: 'Ocean Cleanup Initiative', reason: 'Schedule conflict' },
TRAVEL_CONFIRMED: {
projectTitle: 'Ocean Cleanup Initiative',

View File

@@ -0,0 +1,272 @@
import type { PrismaClient } from '@prisma/client'
import { createNotification, NotificationTypes } from './in-app-notification'
import { getPresignedUrl } from '@/lib/minio'
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, roundId: round.id, 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 required = reqStatuses.filter((r) => r.isRequired)
const allRequiredUploaded = required.length > 0 && required.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,
}
}
/** 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: `/applicant/documents`, // relative → client-side nav in-app; email renderer absolutizes
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 }
}
/**
* 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 }
}
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[] }
/**
* Read-only review payload of every finalist team enrolled in the program's
* active LIVE_FINAL round, with their uploaded grand-final documents. Each
* present file carries a server-generated GET presigned URL (1h) so finale
* judges — who are not assignment-gated through file.getDownloadUrl — can open
* the documents directly in the browser.
*/
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, roundId: round.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
}

View File

@@ -105,6 +105,8 @@ export const NotificationTypes = {
FINALIST_WITHDRAWN: 'FINALIST_WITHDRAWN',
TRAVEL_CONFIRMED: 'TRAVEL_CONFIRMED',
VISA_STATUS_UPDATE: 'VISA_STATUS_UPDATE',
GRAND_FINAL_DOCS_REMINDER: 'GRAND_FINAL_DOCS_REMINDER',
GRAND_FINAL_DOCS_SUBMITTED: 'GRAND_FINAL_DOCS_SUBMITTED',
} as const
export type NotificationType = (typeof NotificationTypes)[keyof typeof NotificationTypes]

View File

@@ -0,0 +1,300 @@
import { describe, it, expect, afterAll } from 'vitest'
import { TRPCError } from '@trpc/server'
import { prisma } from '../setup'
import {
createTestProgram,
createTestCompetition,
createTestRound,
createTestProject,
createTestProjectRoundState,
createTestUser,
cleanupTestData,
uid,
} from '../helpers'
import {
getFinalDocumentStatusForProject,
sendManualFinalDocReminders,
sendDueFinalDocReminders,
} from '@/server/services/final-documents'
import * as applicantRouter from '@/server/routers/applicant'
import * as finalistRouter from '@/server/routers/finalist'
import * as mentorRouter from '@/server/routers/mentor'
import { createCaller } from '../setup'
import { BUCKET_NAME, generateObjectKey } from '@/lib/minio'
const programIds: string[] = []
async function makeFinaleProgram(
opts: { roundStatus?: 'ROUND_ACTIVE' | 'ROUND_DRAFT'; closeAt?: Date; skipRequirements?: boolean } = {},
) {
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),
})
if (opts.skipRequirements) {
return { program, comp, round, reqPlan: undefined, reqVideo: undefined }
}
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()
})
it('reports allRequiredUploaded false when the round has no required requirements', async () => {
const { program, round } = await makeFinaleProgram({ skipRequirements: true })
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(0)
expect(status!.allRequiredUploaded).toBe(false)
})
})
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()
})
})
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()
})
})
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
})
})
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)
})
})
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()
})
})
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: BUCKET_NAME, objectKey: generateObjectKey(project.title, 'exec.pdf'), 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()
})
})