Files
MOPC-Portal/docs/superpowers/specs/2026-06-09-grand-final-documents-design.md
Matt c2afa58606 docs(grand-final): 4-doc set (PDF-only), thin dedicated judge page rationale
Confirmed document set: Final Presentation, Final Business Plan, 1-min Video,
Executive Summary (all required, PDF-only docs), same for both categories.
Judge page stays a thin dedicated page reusing the existing doc viewer because
the finale has 0 assignments / empty jury group (group-based, not assignment-based).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:49:26 +02:00

17 KiB
Raw Blame History

Grand-Final Documents — upload visibility, mentor surfacing, judge review, notifications

Date: 2026-06-09 Status: Design — pending review Edition: MOPC 2026 (program "Monaco Ocean Protection Challenge", competition MOPC 2026)

Problem

Nine finalist teams must submit two final deliverables ahead of the Grand Final — a final PDF presentation and a ~1-minute video — and the upcoming Grand-Final judges need to review those documents. Today there is no discoverable upload prompt, no consolidated judge review surface, and no notifications driving teams to upload.

Current state (verified against prod, 2026-06-09)

The data layer already exists and is correctly set up:

  • "Grand Final" (LIVE_FINAL) round is ROUND_ACTIVE with windowCloseAt = 2026-06-11 21:00 UTC and the empty "Finals Jury" group attached (juryGroupId set, 0 members).
  • Two FileRequirement rows already exist on the Grand Final round (legacy per-round system): "PDF presentation support" (application/pdf) and "1 minute video" (video/*). Both currently required = false, maxSizeMB = null, 0 files uploaded. This set is being expanded to the confirmed 4-document set below.
  • All 9 finalist teams are correctly enrolled: each has a ProjectRoundState in both the (closed) Mentoring round and the (active) Grand Final round, and all 9 have FinalistConfirmation.status = CONFIRMED. No mismatches (confirmed↔enrolled↔in-mentor all aligned). All 9 share one mentor, Camille Lopez. Attendee counts 14 (program defaultAttendeeCap = 4).
  • Auto-enroll (confirmed + in mentor round → Grand Final round) is working via finalist.enrollFinalists; the admin override already exists (finalist.adminConfirm to mark attending without a token; finalist.unenroll to remove) in the /admin/logistics Confirmations tab + attendance dialog.

What this means

The upload already works today: /applicant/documents renders an upload section for every round in openRounds, where openRounds = program rounds that are ROUND_ACTIVE and the project is a member of (applicant.getMyDashboard). The Grand Final round qualifies, so all 9 teams can upload the PDF + video right now. The upload procedure (applicant.getUploadUrl) permits any team member to upload against a requirement as long as the round is ROUND_ACTIVE (it is). The windowCloseAt deadline is advisory only (display, not blocking).

The gaps are therefore: discoverability (no banner/notification), judge review (no consolidated surface; jury can only see files for projects they are individually assigned to, and the Finals Jury group is empty), and mentor-section surfacing (the final documents never appear in the mentor area).

Goals

  1. Make the existing finalist upload discoverable via a dashboard banner and notifications.
  2. Give the upcoming Grand-Final judges a read-only review page of all finalists' documents.
  3. Surface the final documents (and a pre-deadline cue) inside the mentor section, on both the team's and the mentor's views.
  4. Add email + in-app notifications, triggered automatically (pre-deadline reminder cron) and manually (admin blast).

Document set (confirmed)

The Grand Final round's FileRequirement rows are reconfigured to four required documents, identical for both categories (STARTUP and BUSINESS_CONCEPT) — the per-round FileRequirement model already applies one set to all teams in the round:

  1. Final Presentationapplication/pdf (rename of the existing "PDF presentation support" row)
  2. Final Business Planapplication/pdf (new)
  3. 1-minute Videovideo/* (existing "1 minute video" row)
  4. Executive Summaryapplication/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 FileRequirementProjectFile 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 viewsrc/app/(applicant)/applicant/mentor/page.tsx (adds a panel below the existing mentor cards / chat / workspace-files). Uses applicant.getFinalDocumentStatus.
  • Mentor viewsrc/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 ProjectFiles — 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.sendDocumentReminderssendManualFinalDocReminders. 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.