Compare commits
21 Commits
a6fc697e4d
...
696d7e9041
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
696d7e9041 | ||
|
|
f61dcfa89a | ||
|
|
146691be00 | ||
|
|
e4f13aaed4 | ||
|
|
6e1dcc8cbf | ||
|
|
24c7c4bc6c | ||
|
|
8c6a59bad9 | ||
|
|
b66e2071f9 | ||
|
|
df0be6bb48 | ||
|
|
e9e072dda7 | ||
|
|
b0a0a71cfe | ||
|
|
61bf5a4032 | ||
|
|
26709e2c9b | ||
|
|
f3d3a21156 | ||
|
|
9e058e6ad7 | ||
|
|
16e0a08f16 | ||
|
|
c53ec23109 | ||
|
|
b1923cf0e1 | ||
|
|
b757aae551 | ||
|
|
c2afa58606 | ||
|
|
537de07245 |
1646
docs/superpowers/plans/2026-06-09-grand-final-documents.md
Normal file
1646
docs/superpowers/plans/2026-06-09-grand-final-documents.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 1–4 (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.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "FinalistConfirmation" ADD COLUMN "finalDocsReminderSentAt" TIMESTAMP(3);
|
||||||
@@ -2762,6 +2762,7 @@ model FinalistConfirmation {
|
|||||||
declineReason String? // optional free-text on decline
|
declineReason String? // optional free-text on decline
|
||||||
expiredAt DateTime?
|
expiredAt DateTime?
|
||||||
reminderSentAt DateTime? // set when the pre-deadline reminder is sent (cron)
|
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
|
promotedFromWaitlistEntryId String? @unique // null for original finalists
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
@@ -271,6 +271,20 @@ const NOTIFICATION_EMAIL_SETTINGS = [
|
|||||||
description: 'Email to the attendee when their visa application status changes',
|
description: 'Email to the attendee when their visa application status changes',
|
||||||
sendEmail: true,
|
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)
|
// Admin notifications (in-app only by default)
|
||||||
{
|
{
|
||||||
|
|||||||
33
scripts/configure-grand-final-requirements.mjs
Normal file
33
scripts/configure-grand-final-requirements.mjs
Normal 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())
|
||||||
7
src/app/(admin)/admin/finals-documents/page.tsx
Normal file
7
src/app/(admin)/admin/finals-documents/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { FinalsDocumentsReview } from '@/components/finals/finals-documents-review'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export default function AdminFinalsDocumentsPage() {
|
||||||
|
return <FinalsDocumentsReview />
|
||||||
|
}
|
||||||
@@ -96,6 +96,7 @@ import { MentoringProjectsTable } from '@/components/admin/round/mentoring-proje
|
|||||||
import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card'
|
import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card'
|
||||||
import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card'
|
import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card'
|
||||||
import { FinalistEnrollmentCard } from '@/components/admin/grand-finale/finalist-enrollment-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 { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
|
||||||
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
||||||
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
||||||
@@ -1529,6 +1530,15 @@ export default function RoundDetailPage() {
|
|||||||
{isGrandFinale && programId && (
|
{isGrandFinale && programId && (
|
||||||
<>
|
<>
|
||||||
<FinalistEnrollmentCard programId={programId} roundId={roundId} />
|
<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">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<FinalistSlotsCard programId={programId} />
|
<FinalistSlotsCard programId={programId} />
|
||||||
<WaitlistCard programId={programId} />
|
<WaitlistCard programId={programId} />
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { MentorChat } from '@/components/shared/mentor-chat'
|
import { MentorChat } from '@/components/shared/mentor-chat'
|
||||||
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
|
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
|
||||||
|
import { FinalDocumentsPanel } from '@/components/applicant/final-documents-panel'
|
||||||
import { RequestChangeDialog } from './request-change-dialog'
|
import { RequestChangeDialog } from './request-change-dialog'
|
||||||
import {
|
import {
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
@@ -216,6 +217,9 @@ export default function ApplicantMentorPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Final Documents (self-hides when not a finalist) */}
|
||||||
|
<FinalDocumentsPanel variant="team" />
|
||||||
|
|
||||||
{/* Request change dialog */}
|
{/* Request change dialog */}
|
||||||
{projectId && (
|
{projectId && (
|
||||||
<RequestChangeDialog
|
<RequestChangeDialog
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { MentoringRequestCard } from '@/components/applicant/mentoring-request-c
|
|||||||
import { MentorConversationCard } from '@/components/applicant/mentor-conversation-card'
|
import { MentorConversationCard } from '@/components/applicant/mentor-conversation-card'
|
||||||
import { AttendingMembersCard } from '@/components/applicant/attending-members-card'
|
import { AttendingMembersCard } from '@/components/applicant/attending-members-card'
|
||||||
import { MyLogisticsCard } from '@/components/applicant/my-logistics-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 { LunchBanner } from '@/components/applicant/lunch-banner'
|
||||||
import { ExternalAttendeesStrip } from '@/components/applicant/external-attendees-strip'
|
import { ExternalAttendeesStrip } from '@/components/applicant/external-attendees-strip'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
@@ -206,6 +207,9 @@ export default function ApplicantDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Grand Final document upload banner (auto-hides for non-finalists) */}
|
||||||
|
<FinalDocumentsBanner />
|
||||||
|
|
||||||
{/* Active round deadline banner */}
|
{/* Active round deadline banner */}
|
||||||
{!isRejected && openRounds.length > 0 && (() => {
|
{!isRejected && openRounds.length > 0 && (() => {
|
||||||
const submissionTypes = new Set(['INTAKE', 'SUBMISSION', 'MENTORING'])
|
const submissionTypes = new Set(['INTAKE', 'SUBMISSION', 'MENTORING'])
|
||||||
|
|||||||
7
src/app/(jury)/jury/finals-documents/page.tsx
Normal file
7
src/app/(jury)/jury/finals-documents/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { FinalsDocumentsReview } from '@/components/finals/finals-documents-review'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export default function FinalsDocumentsPage() {
|
||||||
|
return <FinalsDocumentsReview />
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { WorkspaceChat } from '@/components/mentor/workspace-chat'
|
import { WorkspaceChat } from '@/components/mentor/workspace-chat'
|
||||||
import { FilePromotionPanel } from '@/components/mentor/file-promotion-panel'
|
import { FilePromotionPanel } from '@/components/mentor/file-promotion-panel'
|
||||||
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-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 { ArrowLeft, MessageSquare, FileText, Upload, Users } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
@@ -186,6 +187,9 @@ export default function MentorWorkspaceDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Final Documents (self-hides when not a finalist) */}
|
||||||
|
<FinalDocumentsPanel variant="mentor" projectId={projectId} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
17
src/app/api/cron/final-document-reminders/route.ts
Normal file
17
src/app/api/cron/final-document-reminders/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
src/components/applicant/final-documents-banner.tsx
Normal file
61
src/components/applicant/final-documents-banner.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
src/components/applicant/final-documents-panel.tsx
Normal file
59
src/components/applicant/final-documents-panel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
147
src/components/finals/finals-documents-review.tsx
Normal file
147
src/components/finals/finals-documents-review.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'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 { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -61,6 +61,11 @@ export function JuryNav({ user }: JuryNavProps) {
|
|||||||
href: '/jury/competitions',
|
href: '/jury/competitions',
|
||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Finalist Documents',
|
||||||
|
href: '/jury/finals-documents',
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
...(myAwards && myAwards.length > 0
|
...(myAwards && myAwards.length > 0
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export const authConfig: NextAuthConfig = {
|
|||||||
'/lunch/pick', // external attendees pick a dish via signed token (no account)
|
'/lunch/pick', // external attendees pick a dish via signed token (no account)
|
||||||
'/api/auth',
|
'/api/auth',
|
||||||
'/api/trpc', // tRPC handles its own auth via procedures
|
'/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
|
// Check if it's a public path
|
||||||
|
|||||||
@@ -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.
|
* 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()),
|
new Date((ctx.metadata?.deadline as string) || Date.now()),
|
||||||
ctx.linkUrl || '',
|
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) =>
|
FINALIST_WITHDRAWN: (ctx) =>
|
||||||
getFinalistWithdrawnTemplate(
|
getFinalistWithdrawnTemplate(
|
||||||
ctx.name || '',
|
ctx.name || '',
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import { generateLogoKey, createStorageProvider, type StorageProviderType } from
|
|||||||
import { getImageUploadUrl, confirmImageUpload, getImageUrl, deleteImage, type ImageUploadConfig } from '@/server/utils/image-upload'
|
import { getImageUploadUrl, confirmImageUpload, getImageUrl, deleteImage, type ImageUploadConfig } from '@/server/utils/image-upload'
|
||||||
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
|
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
|
||||||
import { logAudit } from '@/server/utils/audit'
|
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 { checkRequirementsAndTransition, triggerInProgressOnActivity, transitionProject, isTerminalState } from '../services/round-engine'
|
||||||
|
import { getFinalDocumentStatusForProject } from '../services/final-documents'
|
||||||
import { EvaluationConfigSchema, MentoringConfigSchema } from '@/types/competition-configs'
|
import { EvaluationConfigSchema, MentoringConfigSchema } from '@/types/competition-configs'
|
||||||
import type { PrismaClient, Prisma, RoundType } from '@prisma/client'
|
import type { PrismaClient, Prisma, RoundType } from '@prisma/client'
|
||||||
|
|
||||||
@@ -498,6 +499,24 @@ export const applicantRouter = router({
|
|||||||
console.warn('[DocAnalyzer] Post-upload analysis failed:', err))
|
console.warn('[DocAnalyzer] Post-upload analysis failed:', err))
|
||||||
).catch(() => {})
|
).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
|
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.
|
* Lightweight flags for conditional nav rendering.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -54,6 +54,20 @@ export const competitionRouter = router({
|
|||||||
return competition
|
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
|
* Get competition by ID with rounds, jury groups, and submission windows
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
resetOrCreatePendingConfirmation,
|
resetOrCreatePendingConfirmation,
|
||||||
confirmAttendanceInTx,
|
confirmAttendanceInTx,
|
||||||
} from '../services/finalist-enrollment'
|
} from '../services/finalist-enrollment'
|
||||||
|
import { sendManualFinalDocReminders, listFinalistDocumentsForReview, userCanReviewFinals } from '../services/final-documents'
|
||||||
|
|
||||||
export const finalistRouter = router({
|
export const finalistRouter = router({
|
||||||
/** List all per-category finalist slot quotas for a program. */
|
/** List all per-category finalist slot quotas for a program. */
|
||||||
@@ -1660,4 +1661,33 @@ export const finalistRouter = router({
|
|||||||
|
|
||||||
return { ok: true }
|
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)
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
signMentorUploadToken,
|
signMentorUploadToken,
|
||||||
verifyMentorUploadToken,
|
verifyMentorUploadToken,
|
||||||
} from '@/lib/mentor-upload-token'
|
} from '@/lib/mentor-upload-token'
|
||||||
|
import { getFinalDocumentStatusForProject } from '../services/final-documents'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True if the project is enrolled in a MENTORING round that is still
|
* True if the project is enrolled in a MENTORING round that is still
|
||||||
@@ -215,6 +216,21 @@ async function assertProjectWorkspaceAccess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const mentorRouter = router({
|
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
|
* Get AI-suggested mentor matches for a project
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -119,6 +119,11 @@ const NOTIFICATION_SAMPLE_DATA: Record<string, Record<string, unknown>> = {
|
|||||||
FINALIST_EXPIRED: { projectTitle: 'Ocean Cleanup Initiative', category: 'STARTUP' },
|
FINALIST_EXPIRED: { projectTitle: 'Ocean Cleanup Initiative', category: 'STARTUP' },
|
||||||
FINALIST_WAITLIST_PROMOTED: { projectTitle: 'Reef Guardians', 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() },
|
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' },
|
FINALIST_WITHDRAWN: { projectTitle: 'Ocean Cleanup Initiative', reason: 'Schedule conflict' },
|
||||||
TRAVEL_CONFIRMED: {
|
TRAVEL_CONFIRMED: {
|
||||||
projectTitle: 'Ocean Cleanup Initiative',
|
projectTitle: 'Ocean Cleanup Initiative',
|
||||||
|
|||||||
272
src/server/services/final-documents.ts
Normal file
272
src/server/services/final-documents.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -105,6 +105,8 @@ export const NotificationTypes = {
|
|||||||
FINALIST_WITHDRAWN: 'FINALIST_WITHDRAWN',
|
FINALIST_WITHDRAWN: 'FINALIST_WITHDRAWN',
|
||||||
TRAVEL_CONFIRMED: 'TRAVEL_CONFIRMED',
|
TRAVEL_CONFIRMED: 'TRAVEL_CONFIRMED',
|
||||||
VISA_STATUS_UPDATE: 'VISA_STATUS_UPDATE',
|
VISA_STATUS_UPDATE: 'VISA_STATUS_UPDATE',
|
||||||
|
GRAND_FINAL_DOCS_REMINDER: 'GRAND_FINAL_DOCS_REMINDER',
|
||||||
|
GRAND_FINAL_DOCS_SUBMITTED: 'GRAND_FINAL_DOCS_SUBMITTED',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type NotificationType = (typeof NotificationTypes)[keyof typeof NotificationTypes]
|
export type NotificationType = (typeof NotificationTypes)[keyof typeof NotificationTypes]
|
||||||
|
|||||||
300
tests/unit/final-documents.test.ts
Normal file
300
tests/unit/final-documents.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user