Compare commits

..

89 Commits

Author SHA1 Message Date
Matt
2945a92193 feat(finale): per-project presentation/Q&A durations in m:ss + config-save merge fix
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m30s
- setProjectTiming stores per-project overrides in round config; phase starts
  resolve: explicit input > project override > round default
- Run Order rows get m:ss inputs per project; PhaseControls one-off overrides
  now also m:ss (shared parseClock: '7:30', '12:05', plain '7')
- CRITICAL: round.update now MERGES validated form config over the existing
  configJson — saving the Config tab was wiping projectOrder (would have
  destroyed a running ceremony) and the finals-docs upload toggle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:14:49 +02:00
Matt
9b56eb27fb fix(finale): live-verification fixes — session sync on start, honest vote form, reveal panel polish
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m43s
Found by driving the full ceremony end-to-end in the browser:
- live.start + startPresentation sync LiveVotingSession (status/currentProjectId)
  — jury votes silently failed when the admin never used sendToScreens
- LiveVotingForm no longer shows 'submitted' on a failed mutation; pages
  re-key on votedAt so async vote data renders the right state after refresh
- reveal compose prefers DELIB_LOCKED deliberation results over jury score
  order (listSessions now includes results); Arm unlocks right after save
- deliberation jury page review cards re-key on revision

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 19:24:45 +02:00
Matt
160333c2f9 fix(finale): ceremony-day rate-limit bucket — venue NAT would 429 audience polling
Requests consisting only of cheap public ceremony reads (+ token-gated vote
casts) get a 6000/min per-IP budget instead of 100/min. Vote integrity is
enforced by the token + AudienceFavoriteVote IP cap, not the rate limiter.
Found live: big-screen polling hit 429 within minutes in verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:45:15 +02:00
Matt
f7fdfdec9b test(finale): tally audit — weighted criteria math, ordering, ties, favorites separation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:35:44 +02:00
Matt
a2c6baf718 feat(finale): full ceremony UI — admin console, jury phases+notes, deliberation flow, audience voting, big-screen view
- Admin Ceremony tab: phase driver with real server timers + overtime, run
  order with category grouping + send-to-screens, audience windows + QR
  dialog, override slides, timing log, results reveal builder/stepper
- Admin Deliberation tab: per-category session creation from the round's
  jury group, open/close voting, tally/runoff/override/finalize
- Jury live page: ON_DECK/PRESENTING/QA/SCORING aware, autosaved notes,
  vote comments; LiveVotingForm fixed (criteria score >10 rejection bug,
  permanent lock-out after submit) and made revisable
- Jury deliberation page: identityless submitVote, review-before-rank
  context (finale scores editable, ceremony notes, docs link)
- Jury nav: Live Ceremony + per-category Deliberation links
- Public: rebuilt QR audience voting page (anonymous-safe, favorite-pick
  windows, change-until-close), new /live/ceremony/[roundId] projector view
  with animated reveal, confetti, audience QR slide, override slides

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:34:50 +02:00
Matt
c9dc1bfabd feat(finale): deliberation jury identity resolution, rankable projects, score-revision path, session sync
- submitVote resolves the caller's JuryGroupMember participant row server-side
  (was comparing JuryGroupMember id to User id — every juror got FORBIDDEN)
- getSessionWithVotes now includes category projects so the ranking form has
  data before finalize
- liveVoting.vote accepts any finale-ordered project (revision during
  deliberation); timed window still applies to the live project
- live.sendToScreens keeps LiveVotingSession.currentProjectId/status in sync

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:15:45 +02:00
Matt
4e6904fa12 feat(finale): reveal controller + public ceremony-state endpoint (no-leak guaranteed)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:12:00 +02:00
Matt
45b007334e feat(finale): vote comments, by-round session lookup, my-finale-inputs query
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:10:32 +02:00
Matt
6d2fa3369f feat(finale): audience favorite-vote windows with category gating + IP cap
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:09:02 +02:00
Matt
6eccfc694e feat(finale): per-project phase machine, server timers, overtime log, juror notes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:07:02 +02:00
Matt
97ef3e59ac feat(finale): server-stamped phase timer helper
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:04:51 +02:00
Matt
a66bd728cd feat(finale): schema for phases, audience windows, favorite votes, notes, reveal
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:04:25 +02:00
Matt
64f88890f5 fix(auth): make audience vote, live-scores and ceremony routes public
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:02:08 +02:00
Matt
dcd85c9b13 docs(finale): implementation plan for grand-finale ceremony system
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:01:12 +02:00
Matt
3be1fcd24a docs(finale): approved design spec for grand-finale ceremony system (option C)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:54:31 +02:00
Matt
d38fe7887a feat(final-docs): optional-mode banner/panel + admin 'Documents shown to judges' picker
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m31s
- finalist banner/panel render an optional-uploads state (settle to green via
  hasRequired ? allRequiredUploaded : allUploaded; 'optional' copy when nothing required)
- ReviewDocsPicker admin card on the LIVE_FINAL round page to curate judge-visible docs
2026-06-10 15:02:19 +02:00
Matt
28ca7bb0a6 feat(final-docs): judge-doc curation — reviewVisibleRequirementIds filter, picker helper, admin procedures
- listFinalistDocumentsForReview filters prior-round files by the round's
  reviewVisibleRequirementIds (finale uploads always shown; null = show all)
- listReviewVisibilityOptions: distinct prior-round slots + file counts for the picker
- finalist.getReviewDocSettings / setReviewVisibleRequirements (adminProcedure, audited,
  preserves sibling configJson keys)
2026-06-10 15:00:49 +02:00
Matt
d89f67ba57 feat(final-docs): hasRequired/allUploaded on FinalDocumentStatus for optional-uploads mode 2026-06-10 14:58:39 +02:00
Matt
2c311bc65a feat(finals): prominent finalist-docs banner on jury dashboard + gate nav to finals jury
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m37s
Grand-Final jurors couldn't find the finalist documents review: the only entry
point was a top-nav text link (hidden in the hamburger on mobile), with nothing
on the dashboard. Add a prominent dashboard banner (shown only to finals-jury
members) linking to /jury/finals-documents, and gate the nav "Finalist Documents"
link to members so other jurors don't hit a dead "No access" page.

- finalist.canReviewDocuments: lightweight boolean procedure (self-resolves active
  program) so the nav can gate the link without fetching the full payload
- jury-nav: show "Finalist Documents" only when canReviewDocuments
- jury dashboard: FinalsJuryBanner server component, gated via userCanReviewFinals,
  rendered above content regardless of assignment state

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:56:02 +02:00
Matt
85937ec942 docs(final-docs): implementation plan for judge-doc curation + optional uploads
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:06:20 +02:00
Matt
81352d7bd2 docs(final-docs): spec for judge-doc curation + optional revised uploads
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:59:48 +02:00
Matt
8a4184d20f feat(final-docs): judges see all teams' prior-round files; revised uploads behind admin toggle
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m47s
The finals jury needs the teams' EXISTING submissions (pitch deck, exec summary,
business plan, videos from prior rounds) — which all 9 teams already have. So:

- listFinalistDocumentsForReview now returns ALL of each finalist team's files
  across every round (labeled by doc type + round; finale uploads flagged
  'Revised for finals'), with presigned URLs. NOT gated — judges always see.
- Revised re-uploads are now an admin toggle (Round.configJson.allowFinalistRevisedUploads,
  default OFF): gates the banner/panel (getFinalDocumentStatusForProject), the
  upload guard (getUploadUrl/deleteFile), the documents-page round, and the
  reminders (manual + cron). When off, teams aren't prompted/able to upload.
- finalist.get/setRevisedUploadSetting + a Switch on the admin finale overview.
- judge review component rewritten to a per-team labeled file list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:19:09 +02:00
Matt
f8f2d77e3b feat(final-docs): decouple grand-final docs from LIVE_FINAL being ROUND_ACTIVE
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m44s
The Grand Final round = the live event; document upload + judge review happen
in the lead-up BEFORE it opens. So gate them on finalist enrollment + the round
being open-for-docs (DRAFT or ACTIVE, not closed/finalized) instead of requiring
ROUND_ACTIVE. Lets the round stay DRAFT until event time.

- getOpenFinaleRound (was getActiveFinaleRound): status in {DRAFT,ACTIVE}, not finalized
- cron + userCanReviewFinals use the same open-status condition
- getUploadUrl + deleteFile allow a not-yet-closed LIVE_FINAL round
- getMyDashboard openRounds includes the enrolled DRAFT LIVE_FINAL round (finalists only)
- tests: DRAFT now works; CLOSED returns null

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:57:24 +02:00
Matt
696d7e9041 fix(final-docs): exclude dropped mentors from doc access; relative in-app reminder link
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m43s
Review follow-ups: mentor.getProjectFinalDocuments now filters droppedAt:null
(a dropped mentor no longer sees a team's final docs); reminder linkUrl is
relative so the in-app notification does client-side nav (email still absolutized).

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:56:46 +02:00
Matt
24c7c4bc6c feat(final-docs): Final Documents panel on team + mentor views
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:53:39 +02:00
Matt
8c6a59bad9 feat(final-docs): mentor.getProjectFinalDocuments procedure
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:50:42 +02:00
Matt
b66e2071f9 refactor(final-docs): shared review component reachable by jury + admin routes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:47:56 +02:00
Matt
df0be6bb48 feat(final-docs): judge review page + entry points
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:44:37 +02:00
Matt
e9e072dda7 feat(final-docs): finalist document review service + procedure with finale-access gate
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:36:59 +02:00
Matt
b0a0a71cfe chore(final-docs): script to configure the 4 grand-final file requirements 2026-06-09 15:33:25 +02:00
Matt
61bf5a4032 feat(final-docs): admin manual reminder button
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:31:18 +02:00
Matt
26709e2c9b feat(final-docs): manual admin document-reminder blast
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:26:50 +02:00
Matt
f3d3a21156 feat(final-docs): GRAND_FINAL_DOCS_REMINDER/SUBMITTED notification types + email template
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:23:44 +02:00
Matt
9e058e6ad7 feat(final-docs): finalist upload banner on applicant dashboard 2026-06-09 15:21:08 +02:00
Matt
16e0a08f16 feat(final-docs): applicant.getFinalDocumentStatus procedure 2026-06-09 15:17:47 +02:00
Matt
c53ec23109 fix(final-docs): round-scope file query + guard empty-required edge case 2026-06-09 15:15:22 +02:00
Matt
b1923cf0e1 feat(final-docs): grand-final document status service 2026-06-09 15:09:50 +02:00
Matt
b757aae551 docs(grand-final): implementation plan (4 phases, TDD)
Phase 1: foundation service + finalist banner + manual reminder + 4-doc reconfig
Phase 2: judge review page (finale-access gate, embedded presigned URLs)
Phase 3: mentor-section Final Documents panel (team + mentor)
Phase 4: auto reminder cron + on-upload mentor notification + migration

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:57:19 +02:00
Matt
c2afa58606 docs(grand-final): 4-doc set (PDF-only), thin dedicated judge page rationale
Confirmed document set: Final Presentation, Final Business Plan, 1-min Video,
Executive Summary (all required, PDF-only docs), same for both categories.
Judge page stays a thin dedicated page reusing the existing doc viewer because
the finale has 0 assignments / empty jury group (group-based, not assignment-based).

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:31:49 +02:00
Matt
a6fc697e4d fix(auth): allow /lunch/pick public access for accountless external attendees
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m30s
The external dish-picker page is reached via a signed token by attendees who
have no account. The middleware authorized() callback redirected any non
allowlisted path to /login, which is a dead end for accountless users — so the
picker shipped in 8d4f0ba was unreachable in prod (307 → /login). Add
/lunch/pick to publicPaths; data stays gated by token verification in tRPC.

Adds a regression test asserting the path is public and a protected path is not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:18:10 +02:00
Matt
8d4f0bac1e feat(logistics): external attendees self-select lunch dish via tokenized page
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m51s
External lunch attendees had no way to pick their own dish — an admin had to set
it inline and no email was ever sent. (Marine added herself as an external
expecting a dish-selection link and never received one.)

Adds:
- ExternalAttendee.inviteSentAt + additive migration
- HMAC-signed external lunch token (mirrors finalist-token)
- Public no-login picker page /lunch/pick/[token] — dish + allergens + notes,
  gated by the lunch change deadline, read-only after
- tRPC getExternalByToken / setExternalPick (public) + sendExternalInvite (admin)
- Auto-send invite on createExternal when an email is present; per-row resend
  button + status chip (Invited / Picked / no email) in the logistics screen
- Unpicked externals chased by the lunch reminder cron + manual "Send reminders"
- sendExternalDishInviteEmail (branded). Page + email title use the configurable
  venue ("Lunch at {venue}") rather than "grand finale"

Tests: token roundtrip/tamper/expiry, selectUnpickedExternals filter,
get/set-by-token happy + deadline + bad-token, createExternal auto-send,
cron external reminders. Full suite 303 passing; build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:04:13 +02:00
Matt
f2c8cc1e80 fix(logistics): Hotels tab crash — Radix SelectItem cannot have empty value
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m58s
The 'Unassigned' rooming option used value="" which throws at runtime
(blank tab behind the error boundary). Use a sentinel value mapped to unassign.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:50:18 +02:00
Matt
89ab5cc8e6 test(logistics): remove obsolete single-hotel test (superseded by logistics-hotels)
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m20s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:25:41 +02:00
Matt
3bbc80332c feat(logistics): Hotels tab — multi-hotel management + rooming assignment
Rewrites hotels-tab.tsx from the removed single-hotel getHotel/upsertHotel
pattern to the new multi-hotel API: Hotels section (list/add/edit/delete with
occupancy badge) + Rooming section (per-attendee hotel+room+dates assignment,
team-assign shortcut, CSV export).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 19:24:06 +02:00
Matt
9313eb96f0 feat(applicant): My Logistics shows assigned hotel + room
Replace the program-level hotel.findUnique (broken after removing @unique)
with the caller's HotelStay (include hotel) on their AttendingMember.
Returns hotel: {name,address,link,notes}|null and room: {roomNumber,
checkInAt,checkOutAt}|null. MyLogisticsCard renders the Room section
(number + Monaco-time check-in/out) when room is present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 19:21:29 +02:00
Matt
4cd2651f9c feat(logistics): hotels CRUD + rooming + assignment procedures + travel email
Replace getHotel/upsertHotel with listHotels/createHotel/updateHotel/deleteHotel
(multi-hotel per edition). Add listRooming, assignStay, assignTeamToHotel, and
unassignStay procedures for per-attendee room assignments. Update setFlightStatus
to include attendee's HotelStay in TRAVEL_CONFIRMED notification metadata.
Extend getTravelConfirmedTemplate to render room number and check-in/out dates.
All procedures are adminProcedure and audit-logged. 10 new unit tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 19:19:18 +02:00
Matt
75e63eb47f feat(hotel): many hotels per edition + HotelStay (room assignment)
- Hotel.programId no longer unique (many hotels per edition)
- New HotelStay 1:1 with AttendingMember (hotelId, room, check-in/out)
- Program.hotel -> hotels[]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:13:52 +02:00
Matt
200b5e0cb9 docs(logistics): multi-hotel + rooming implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:09:49 +02:00
Matt
42e6b5f8c0 docs(logistics): multi-hotel + room-assignment design spec
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:08:51 +02:00
Matt
97951deb68 feat(logistics): departure-after-arrival validation + travel/visa CSV export
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m11s
- upsertFlightDetail throws BAD_REQUEST when departureAt < arrivalAt
- Travel tab: Download CSV button (project/attendee/email/flight fields/status/visa)
- Visas tab: Download CSV button (project/attendee/nationality/status/dates/notes)
- TDD: 2 new tests (rejects invalid, accepts valid); all 6 flight tests pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 16:56:22 +02:00
Matt
53b623fb20 feat(applicant): My Logistics card (hotel/flights/visa+nationality) + confirm-page dashboard link
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 16:53:21 +02:00
Matt
74cd111e3a feat(applicant): self-service visa nationality entry
Add updateMyVisaNationality mutation: finds the caller's AttendingMember where the program has visaStatusVisibleToMembers=true and a VisaApplication exists, updates VisaApplication.nationality, and emits a VISA_NATIONALITY_SELF_SET audit log. Throws NOT_FOUND when no eligible application exists. Tests: persists update; rejects caller without a visible visa app.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 16:51:01 +02:00
Matt
d03c705642 feat(applicant): getMyLogistics (hotel+flight+visa) + submitter-match fix
- Fix getMyFinalistConfirmation to resolve project via OR [submittedByUserId, teamMembers] so a lead who submitted but has no TeamMember row can see their card.
- Add getMyLogistics query: returns projectTitle, confirmationStatus, hotel (program 1:1), myFlight (caller's AttendingMember.flightDetail), visaVisible flag, and myVisa when visible. Returns null for non-confirmed or unrelated callers.
- Tests: confirmed finalist sees hotel/flight/visa; non-finalist gets null; PENDING confirmation gets null.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 16:50:14 +02:00
Matt
ed426a6fb4 docs(logistics): Wave 4 plan — team-facing My Logistics + travel/visa UX
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:46:12 +02:00
Matt
ee8d65a300 feat(logistics): enable Email Templates tab
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:41:39 +02:00
Matt
0058b2b73b feat(logistics): Email Templates tab (toggle/subject/preview/test) + logistics in global settings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:41:33 +02:00
Matt
e5788b3e9d feat(notifications): renderNotificationEmail + previewEmailTemplate + logistics sample data
- Export `renderNotificationEmail` from email.ts (pure template resolver, no send)
- Refactor `sendStyledNotificationEmail` to delegate to `renderNotificationEmail`
- Hoist sampleData to module-level `NOTIFICATION_SAMPLE_DATA` in notification router
- Add 8 logistics sample entries (FINALIST_*/TRAVEL_CONFIRMED/VISA_STATUS_UPDATE)
- Add `notification.previewEmailTemplate` adminProcedure query (returns subject/html/hasStyledTemplate)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 16:39:29 +02:00
Matt
27bdf8cdef docs(logistics): Wave 3 plan — enable Email Templates tab
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:36:05 +02:00
Matt
3f25ba112b fix(lunch): reminder filter, recap failure surfacing, manual send-reminders
- Extract selectUnpickedAttendees helper with OR filter (is null OR pickedAt null)
  to fix cron missing attendees with no MemberLunchPick row at all
- Update cron route to use the helper
- sendRecap now throws TRPCError on email failure instead of silently stamping success
- Add lunch.sendReminders adminProcedure for manual on-demand reminder sends
- Add "Send reminders now" AlertDialog button to LunchRecapActions
- Tests: lunch-reminder-filter.test.ts (2 new), all 5 lunch test files pass (40 tests)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:24:01 +02:00
Matt
884c96c710 feat(logistics): travel-confirmed + visa-status emails to attendees
When a flight is set to CONFIRMED, fire a TRAVEL_CONFIRMED in-app
notification (+ email via the existing NotificationEmailSetting pipeline)
to the attending member. When a visa status changes to one of
INVITATION_SENT|APPOINTMENT_BOOKED|GRANTED|DENIED, fire a
VISA_STATUS_UPDATE notification. Both are best-effort (try/catch, never
throw inside the mutation). Admin notes are not forwarded to metadata.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 16:20:21 +02:00
Matt
1b4ab6be18 feat(finalist): deadline reminder emails via cron
Add sendDueConfirmationReminders() to finalist-confirmation.ts: queries
PENDING confirmations with no reminderSentAt whose deadline is within the
per-program LIVE_FINAL round reminderHoursBeforeDeadline window (default 12h),
sends a FINALIST_REMINDER in-app notification (+ email via pipeline) to the
team LEAD, then stamps reminderSentAt for idempotency.

Wire into the finalist-confirmations cron route alongside expirePendingPastDeadline.
Also clear reminderSentAt on re-invite in resetOrCreatePendingConfirmation so
re-invited teams get a fresh reminder window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:17:19 +02:00
Matt
d501624c56 feat(finalist): withdrawal notification to team on decline/unconfirm/unenroll
Notifies the team LEAD with FINALIST_WITHDRAWN (in-app + email) when an admin
withdraws a grand-finale slot via adminDecline, unconfirm, or unenroll.
For unenroll, only notifies when a CONFIRMED confirmation existed before deletion.
Adds finalist-withdrawal.test.ts (4 tests) covering all three paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:14:26 +02:00
Matt
b2826d595f feat(finalist): admin alerts on confirm/decline/expire/promote
Wire notifyAdmins() into finalist lifecycle events so admins receive
in-app (+ email when enabled) notifications on each key transition:
- finalist.confirm → FINALIST_CONFIRMED
- finalist.decline / adminDecline → FINALIST_DECLINED
- expirePendingPastDeadline (per row) → FINALIST_EXPIRED
- promoteNextWaitlistEntry + manualPromote → FINALIST_WAITLIST_PROMOTED

All calls are wrapped in try/catch — comms never throw inside a mutation
or cron (CLAUDE.md: "round notifications never throw"). Added project
title to the project select where it was missing.

Tests: tests/unit/finalist-comms.test.ts — 3 tests passing (TDD).
Regression: finalist-confirmation, finalist-admin-confirm,
finalist-enrollment — 30 tests all passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:11:17 +02:00
Matt
f529e79cd7 feat(comms): logistics notification types, templates, and email settings
- Add 8 constants to NotificationTypes (FINALIST_CONFIRMED/DECLINED/EXPIRED/
  WAITLIST_PROMOTED/REMINDER/WITHDRAWN, TRAVEL_CONFIRMED, VISA_STATUS_UPDATE)
  with matching icons and priorities in NotificationIcons/NotificationPriorities
- Add 4 branded email templates: getFinalistReminderTemplate,
  getFinalistWithdrawnTemplate, getTravelConfirmedTemplate,
  getVisaStatusTemplate — registered in NOTIFICATION_EMAIL_TEMPLATES
  (admin-alert types use generic fallback)
- Add 8 logistics seed rows to seed-notification-settings.ts; upserted to
  dev DB (idempotent)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 16:06:10 +02:00
Matt
0ea949309a feat(finalist): add reminderSentAt for confirmation reminders
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:02:57 +02:00
Matt
8afef1ecba docs(logistics): Wave 2 plan — close the email/notification void
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:02:12 +02:00
Matt
34bb2bad57 fix(finalist): program-scope guards on enroll/unenroll (code review)
- enrollFinalists: reject a roundId whose competition belongs to a
  different program than input.programId.
- unenroll: reject a project/round pair from different programs before
  any delete.
- Hoist ADMIN_CONFIRM attendee validation to a pre-pass so a bad entry
  in a multi-team batch fails before any project is partially written.
- Add regression tests for both cross-program guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:49:13 +02:00
Matt
8ee517f6ca feat(logistics): waitlist populate UI + confirmations-tab un-confirm/re-invite actions
- waitlist-card: AddToWaitlistForm sub-component wires finalist.addToWaitlist (category + project pickers sourced from listEnrollmentCandidates, filtered to exclude confirmed/waitlisted projects; appends at next rank)
- confirmations-tab: fix dead-end empty-state copy to reference Grand Final round Overview tab
- confirmations-tab: CONFIRMED rows → Un-confirm button (AlertDialog with required reason, calls finalist.unconfirm)
- confirmations-tab: DECLINED/EXPIRED rows → Re-invite button (calls enrollFinalists EMAIL mode via liveFinalRoundId from listEnrollmentCandidates)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:36:15 +02:00
Matt
2a98f0cacf feat(grand-finale): finalist enrollment card on LIVE_FINAL round page
Adds EnrollAttendeesDialog and FinalistEnrollmentCard components and
wires the card above FinalistSlotsCard on the LIVE_FINAL round Overview,
giving admins the missing UI entry point to enroll mentoring-round teams
into the Grand Final via EMAIL or ADMIN_CONFIRM mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:32:40 +02:00
Matt
e80710487c feat(finalist): listEnrollmentCandidates query for enrollment UI
Returns mentoring-round candidates grouped by category with status,
team members, quota and confirmed/pending counts; inLiveFinal flag and
attendeeCap for the enrollment UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:29:26 +02:00
Matt
375aeb08af feat(finalist): unenroll reverses round membership + confirmation
Adds finalist.unenroll(projectId, roundId) which deletes the
FinalistConfirmation (cascading AttendingMember/FlightDetail/
VisaApplication/MemberLunchPick) and the LIVE_FINAL ProjectRoundState,
then logs a FINALIST_UNENROLL audit entry. Safe no-op when no rows exist.
Tests cover ADMIN_CONFIRM enrolled teardown and the no-rows path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:23:50 +02:00
Matt
f1e62fdd3b feat(finalist): unified enrollFinalists (round membership + confirmation + email/admin-confirm)
- Add `finalist.enrollFinalists` adminProcedure: creates ProjectRoundState in
  LIVE_FINAL round (skipDuplicates) + resets/creates FinalistConfirmation in
  one step, with EMAIL and ADMIN_CONFIRM attendee modes.
- Extract `confirmAttendanceInTx` helper into finalist-enrollment.ts; reuse
  from both adminConfirm and enrollFinalists (DRY refactor, all tests green).
- Add 4 tests covering EMAIL mode, ADMIN_CONFIRM mode, re-enroll safety, and
  over-cap rejection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:20:51 +02:00
Matt
dde8ea9345 feat(finalist): re-invite-safe confirmation reset helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 15:16:52 +02:00
Matt
ca9edcd038 fix(lunch): allow non-admins to read dish list (unblocks applicant picker) 2026-06-04 15:15:14 +02:00
Matt
647e7f09a7 docs(logistics): Wave 1 plan — finalist enrollment + BLOCKER fixes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:13:01 +02:00
Matt
6b37e9bb9e fix(email): correct member-row spacing in mentor welcome emails
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m34s
Member/mentor/teammate tables used width:100%, stretching the two
columns apart and forcing names to wrap; wrapped names then misaligned
with their email (default vertical-align: middle).

Drop width:100% so tables hug content, add 16px column gap via name-cell
padding, and set vertical-align: top so emails align to the first line
of the name. Applied to getMentorBulkAssignmentTemplate and
getTeamMentorIntroductionTemplate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:11:01 +02:00
Matt
eb891403f1 Merge: populate roles[] on user creation (role in roles invariant)
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m43s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:06:52 +02:00
Matt
60f1a53d70 fix(users): populate roles[] with primary role on user creation
All user-creation paths (admin create, bulk invite import, public application
contact + team members, project team members, jury-group + special-award
invites) now set roles=[role] so the invariant role in roles[] holds for new
users, matching seed.ts and the role-change mutations. Prevents the empty
roles[] inconsistency that hid primary-role mentors from the mentor picker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:06:52 +02:00
Matt
501b4ffdb5 Merge: primary-role mentors assignable on mentor config page
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m51s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:34:40 +02:00
Matt
828c09df6d fix(mentor): primary-role mentors are selectable/assignable
getCandidates, getMentorPool and bulkAssign matched MENTOR via roles[] only, so
a user with role=MENTOR but an empty roles[] array (legacy/seeded records, e.g.
Arnaud Blandin on prod) was excluded from the mentor picker and rejected by
bulk assign. Match MENTOR as primary role OR in roles[], mirroring userHasRole.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:34:40 +02:00
Matt
fe7f133879 Merge: members role tabs include secondary roles
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m1s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:51:11 +02:00
Matt
d4a77f63d3 fix(members): role tabs/filter include users with secondary roles
user.list and user.listInvitableIds filtered on the singular User.role column,
so the type tabs (Jury/Mentor/…) omitted users holding that role as a secondary
role (User.roles[]). Match the role as primary OR secondary (roles hasSome),
combined with search via AND, mirroring userHasRole / hasRole middleware.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:51:11 +02:00
Matt
040e5ff9a9 Merge: brand-align mentorship welcome emails
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m10s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:21:49 +02:00
Matt
652c3ed4f2 fix(email): use shared branded wrapper (logo/footer) for mentorship emails
Migrate getMentorBulkAssignmentTemplate + getTeamMentorIntroductionTemplate to
getEmailWrapper() so they match the other ~40 platform emails: MOPC logo header,
ocean background, big-logo footer, and UTF-8 charset (fixes accent/em-dash
rendering). Body now uses sectionTitle/paragraph/infoBox/ctaButton helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:21:49 +02:00
131 changed files with 24057 additions and 4145 deletions

5
.gitignore vendored
View File

@@ -63,3 +63,8 @@ build-output.txt
private/
public/build-id.json
.remember/
# Local tooling + session screenshots
.claude/
.serena/
/*.png

View File

@@ -0,0 +1,76 @@
# Multiple Hotels + Room Assignments — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
**Goal:** Replace the one-hotel-per-edition model with many hotels + per-attendee room assignments (hotel, room number, check-in/out), assignable per-member or per-team, surfaced to teams and in the travel email.
**Architecture:** New `HotelStay` 1:1 detail record per `AttendingMember` (mirrors `FlightDetail`); `Hotel.programId` becomes non-unique. Logistics router gains list/CRUD + rooming/assignment procedures; the Hotels tab is reworked into Hotels + Rooming sections. Spec: `docs/superpowers/specs/2026-06-04-multi-hotel-rooming-design.md`.
**Tech Stack:** Next.js 15, tRPC 11, Prisma 6, shadcn/ui, Vitest 4. One schema migration (additive + drop one unique constraint).
**Verified facts:** `Hotel` (`schema.prisma`) is `programId @unique`; `FlightDetail` is the 1:1-detail pattern to mirror. 1:1 hotel callers to update: `logistics.ts:13` (`getHotel`), `:17/33` (`upsertHotel`), `:231` (travel email program-hotel lookup), `applicant.ts:2899` (`getMyLogistics`), `hotels-tab.tsx:20/37/40`.
---
## Task 1: Schema — many hotels + `HotelStay`
**Files:** `prisma/schema.prisma`, migration.
- [ ] **Step 1:** Edit `Hotel`: remove `@unique` from `programId`; add `stays HotelStay[]` and `@@index([programId])`.
- [ ] **Step 2:** Add `HotelStay` model exactly as in the spec (1:1 `attendingMemberId @unique`, required `hotelId`, `roomNumber?`, `checkInAt?`, `checkOutAt?`, `notes?`, timestamps; `attendingMember` relation `onDelete: Cascade`; `hotel` relation `onDelete: Restrict`; `@@index([hotelId])`). Add `hotelStay HotelStay?` to `AttendingMember`.
- [ ] **Step 3:** `npx prisma migrate dev --name multi_hotel_and_hotel_stay` → migration created + applied + client regenerated. Confirm the generated SQL drops `Hotel_programId_key`, adds the index, and creates `HotelStay` with both FKs.
- [ ] **Step 4:** `npm run typecheck` (existing `getHotel`/`upsertHotel` still compile until Task 2). Commit: `git add prisma/ && git commit -m "feat(hotel): many hotels per edition + HotelStay (room assignment)"`
---
## Task 2: Logistics router — hotels CRUD + rooming + assignment + travel email
**Files:** `src/server/routers/logistics.ts`; test `tests/unit/logistics-hotels.test.ts`.
Replace `getHotel`/`upsertHotel` with the full set (read the spec table for exact inputs):
- `listHotels({ programId })` — hotels + `_count: { stays }`.
- `createHotel` / `updateHotel` / `deleteHotel` (deleteHotel: pre-count stays; if >0 throw `BAD_REQUEST` "Reassign N occupant(s) first"). All audited.
- `listRooming({ programId })` — one row per CONFIRMED `AttendingMember` in the program: `{ attendingMemberId, projectId, projectTitle, user{id,name,email}, stay: { hotelId, roomNumber, checkInAt, checkOutAt } | null }`, sorted by project title then user name.
- `assignStay({ attendingMemberId, hotelId, roomNumber?, checkInAt?, checkOutAt?, notes? })` — upsert `HotelStay` (validate the hotel belongs to the same program as the attendee). Audit `HOTEL_STAY_ASSIGN`.
- `assignTeamToHotel({ confirmationId, hotelId, checkInAt?, checkOutAt? })` — for each `AttendingMember` of the confirmation, upsert `HotelStay` with `hotelId` (+ optional dates), preserving existing `roomNumber`. Audit `HOTEL_TEAM_ASSIGN`.
- `unassignStay({ attendingMemberId })``deleteMany` (no-op safe). Audit `HOTEL_STAY_UNASSIGN`.
Then update **`setFlightStatus`** (the `TRAVEL_CONFIRMED` path, ~line 231): instead of the program 1:1 hotel, load the attendee's `hotelStay` (with `hotel`) and pass hotel + room/dates into the notification `metadata` (keys: `hotel: { name, address, link }`, `roomNumber`, `checkInAt`, `checkOutAt`). Update `getTravelConfirmedTemplate` (`src/lib/email.ts`) + its `NOTIFICATION_EMAIL_TEMPLATES` entry to render room/dates if present (additive — keep existing fields working).
- [ ] **Step 1: Tests** (`tests/unit/logistics-hotels.test.ts`, mirror `logistics-hotel.test.ts`): create 2 hotels for one program; `deleteHotel` on an occupied hotel rejects, on an empty one succeeds; `assignStay` upsert (create→update room); `assignTeamToHotel` assigns all of a 2-attendee team; `unassignStay` removes; `listRooming` returns the confirmed attendees with/without stays.
- [ ] **Step 2:** Run → fail → implement → pass (`npx vitest run tests/unit/logistics-hotels.test.ts`). Re-run `tests/unit/logistics-flight.test.ts tests/unit/logistics-comms.test.ts` (travel-email change).
- [ ] **Step 3:** `npm run typecheck`. Commit: `feat(logistics): hotels CRUD + rooming + assignment procedures + travel email`
---
## Task 3: Applicant `getMyLogistics` + My Logistics card → assigned hotel/room
**Files:** `src/server/routers/applicant.ts`, `src/components/applicant/my-logistics-card.tsx`; test `tests/unit/applicant-my-logistics.test.ts` (extend).
- [ ] **Step 1:** In `getMyLogistics` (~line 2899), replace the `prisma.hotel.findUnique({ where: { programId } })` with the caller's `AttendingMember.hotelStay` (include `hotel`). Return `hotel: { name, address, link, notes } | null` and `room: { roomNumber, checkInAt, checkOutAt } | null`. Extend the test to seed a `HotelStay` and assert `hotel.name` + `room.roomNumber`.
- [ ] **Step 2:** Update `MyLogisticsCard` Hotel section to also show **Room** (number) + check-in/out (Monaco-time labels) when `room` is present.
- [ ] **Step 3:** Run the applicant test → pass; `npm run typecheck`. Commit: `feat(applicant): My Logistics shows assigned hotel + room`
---
## Task 4: Hotels tab rework — Hotels + Rooming UI
**Files:** `src/components/admin/logistics/hotels-tab.tsx` (rework); optionally split a `rooming-section.tsx`.
- [ ] **Step 1: Hotels section**`trpc.logistics.listHotels`; list cards with name/address/link/notes + occupancy badge (`_count.stays`); Add / Edit (dialog) / Delete (AlertDialog; show the "reassign first" error toast on rejection) wired to create/update/deleteHotel. Invalidate on success.
- [ ] **Step 2: Rooming section**`trpc.logistics.listRooming`; group rows by project (team). Per team header: an **"Assign whole team to…"** Select → `assignTeamToHotel`. Per attendee row: `Hotel` Select (→ `assignStay`, or `unassignStay` when cleared), `Room #` input (blur → `assignStay`), `Check-in` / `Check-out` date inputs (blur → `assignStay`). Hotel options from `listHotels`. Skeleton while loading; empty state "No confirmed attendees yet." **Download CSV** button (Team, Member, Email, Hotel, Room, Check-in, Check-out) mirroring the travel/visa export. Visible affordances only.
- [ ] **Step 3:** `npm run typecheck`. Commit: `feat(logistics): Hotels tab — multi-hotel management + rooming assignment`
---
## Task 5: Verify + deploy
- [ ] **Step 1:** `npx vitest run` — full suite green; `npm run typecheck` clean.
- [ ] **Step 2:** Stop dev, `rm -rf .next`, `npm run build` — clean.
- [ ] **Step 3:** Restart dev on :3001. Dev smoke (admin): Logistics → Hotels tab → add 2 hotels; enroll a team (ADMIN_CONFIRM) so attendees exist; in Rooming, "assign whole team" to hotel A, override one member to hotel B + a room number; verify occupancy counts; check the team-member dashboard shows their assigned hotel + room. Clean up.
- [ ] **Step 4:** Merge to `main` (fast-forward if possible), push, watch Gitea build #N succeed, then redeploy on prod (`ssh stefan@89.58.5.223:22022`, `/opt/letsbe/stacks/mopc-portal`, `docker compose pull && docker compose down && docker compose up -d`**NO -v**), verify migration applied + app healthy + `GET /login` 200.
- [ ] **Step 5:** Summarize.
## Notes
- All comms/assignment writes best-effort where they sit inside other mutations (travel email try/catch already in place).
- Prod migration is additive + one dropped unique constraint (safe; no `HotelStay` data exists yet).

View File

@@ -0,0 +1,467 @@
# Wave 1 — Make Logistics Operable: Finalist Enrollment + BLOCKER fixes
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Give the grand-finale logistics flow its missing entry points so it is operable end-to-end: a unified "Enroll finalists" action that advances mentoring-round teams into the Grand Final round *and* creates their attendance confirmation in one step, a way to populate the waitlist, safe re-invitation, un-enroll, and the lunch-picker permission fix.
**Architecture:** Three new `finalist` tRPC procedures (`listEnrollmentCandidates`, `enrollFinalists`, `unenroll`) plus one shared service helper. The enroll mutation composes three existing mechanisms that are currently disconnected: (a) round membership (`ProjectRoundState` in the LIVE_FINAL round — same pattern as `round.advanceProjects`), (b) finalist confirmation (`createPendingConfirmation` service), and (c) optional immediate admin confirmation (same transaction as `finalist.adminConfirm`). A new admin card on the LIVE_FINAL round Overview drives it. One one-line permission fix unblocks the applicant lunch picker.
**Tech Stack:** Next.js 15 App Router, tRPC 11 (Zod), Prisma 6, shadcn/ui, Vitest 4. No schema migration required (all models already exist).
**Decisions locked (with Matt, 2026-06-04):**
- Enroll is **one unified step**: adds the team to the R7 LIVE_FINAL round (so the Finals Jury sees them) AND creates the finalist confirmation. Un-enroll reverses both.
- **Offer both attendee modes per team** at enroll time: `EMAIL` (send the self-confirm link; team lead picks ≤cap attendees) or `ADMIN_CONFIRM` (admin picks attendees now; status goes straight to CONFIRMED, no email).
---
## File structure
**Create:**
- `src/server/services/finalist-enrollment.ts` — shared helpers: `resetOrCreatePendingConfirmation()` (re-invite-safe) and `confirmAttendanceInTx()` (extracted from adminConfirm, reused by enroll's ADMIN_CONFIRM mode).
- `src/components/admin/grand-finale/finalist-enrollment-card.tsx` — the enrollment UI card on the LIVE_FINAL round Overview.
- `src/components/admin/grand-finale/enroll-attendees-dialog.tsx` — per-team attendee picker for ADMIN_CONFIRM mode.
- `tests/unit/finalist-enrollment.test.ts` — enrollFinalists (both modes), re-invite safety, unified PRS creation.
- `tests/unit/finalist-unenroll.test.ts` — unenroll reverses membership + confirmation.
- `tests/unit/lunch-list-dishes-perm.test.ts` — non-admin can read dishes.
**Modify:**
- `src/server/routers/finalist.ts` — add `listEnrollmentCandidates`, `enrollFinalists`, `unenroll`; refactor `adminConfirm`/`selectFinalists` to reuse the new helpers.
- `src/server/services/finalist-confirmation.ts` — export the reset-safe helper or re-export from the new module (keep `createPendingConfirmation` intact for waitlist promotion).
- `src/server/routers/lunch.ts:42``listDishes`: `adminProcedure``protectedProcedure`.
- `src/app/(admin)/admin/rounds/[roundId]/page.tsx` — render `FinalistEnrollmentCard` in the LIVE_FINAL grand-finale block (currently lines ~15281531, above `FinalistSlotsCard`).
- `src/components/admin/grand-finale/waitlist-card.tsx` — add an "Add to waitlist" control (wires the existing `finalist.addToWaitlist`, which has no UI today).
- `src/components/admin/logistics/confirmations-tab.tsx` — fix the dead-end empty-state copy; add **Un-confirm** and **Re-invite** row actions (surface existing `unconfirm` + new re-invite via `enrollFinalists`).
---
## Background facts the executor needs (verified 2026-06-04)
- **Two disconnected systems.** A project is "in" the Grand Final round iff it has a `ProjectRoundState{ roundId: <LIVE_FINAL>, ... }`. A project is a "confirmed finalist (logistics)" iff it has a `FinalistConfirmation`. `selectFinalists` only ever created the latter and **had zero UI callers**; `addToWaitlist` also had zero UI callers. This wave joins them.
- **Rounds for the live edition:** R5 EVALUATION → **R6 MENTORING (active)****R7 LIVE_FINAL** → R8 DELIBERATION. Candidates to enroll = projects with a `ProjectRoundState` in the MENTORING round.
- **`FinalistConfirmation.projectId` is `@unique`** (`prisma/schema.prisma:2755`). `createPendingConfirmation` does a naive `.create()` → a second invite for a previously DECLINED/EXPIRED project throws Prisma P2002. The new `resetOrCreatePendingConfirmation()` fixes this.
- **Cap:** `Program.defaultAttendeeCap` (live value: 3) bounds attendees per team.
- **Reference code to mirror:**
- PRS creation in target round: `src/server/routers/round.ts:545-551` (`createMany … skipDuplicates`).
- Admin confirm transaction (attendees + visa rows + lunch picks): `src/server/routers/finalist.ts:492-523`.
- Pending-confirmation + token + email: `src/server/services/finalist-confirmation.ts:12-42` and `src/server/routers/finalist.ts:191-219`.
- Candidate data source: `src/server/routers/round.ts:323` (`listMentoringProjects`).
- Test harness: `tests/unit/finalist-admin-confirm.test.ts` (factories, `createCaller`, cleanup pattern).
---
## Task 1: Unblock the applicant lunch picker (BLOCKER)
**Files:**
- Modify: `src/server/routers/lunch.ts:42`
- Test: `tests/unit/lunch-list-dishes-perm.test.ts`
- [ ] **Step 1: Write the failing test** — a non-admin (APPLICANT) can call `listDishes`.
```ts
import { afterAll, describe, expect, it } from 'vitest'
import { prisma, createCaller } from '../setup'
import { createTestUser, createTestProgram, cleanupTestData, uid } from '../helpers'
import { lunchRouter } from '../../src/server/routers/lunch'
describe('lunch.listDishes permission', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
for (const id of programIds) {
await prisma.dish.deleteMany({ where: { lunchEvent: { programId: id } } })
await prisma.lunchEvent.deleteMany({ where: { programId: id } })
await cleanupTestData(id, [])
}
if (userIds.length) await prisma.user.deleteMany({ where: { id: { in: userIds } } })
})
it('lets a non-admin (APPLICANT) read the dish list', async () => {
const program = await createTestProgram({ name: `dish-perm-${uid()}` })
programIds.push(program.id)
const event = await prisma.lunchEvent.create({
data: { programId: program.id, enabled: true },
})
await prisma.dish.create({ data: { lunchEventId: event.id, name: 'Sea bass', sortOrder: 0 } })
const applicant = await createTestUser('APPLICANT')
userIds.push(applicant.id)
const caller = createCaller(lunchRouter, {
id: applicant.id, email: applicant.email, role: 'APPLICANT',
})
const dishes = await caller.listDishes({ lunchEventId: event.id })
expect(dishes).toHaveLength(1)
expect(dishes[0].name).toBe('Sea bass')
})
})
```
> Note: confirm the exact `Dish` field names (`lunchEventId`, `name`, `sortOrder`) against `prisma/schema.prisma` before running; adjust the factory data if they differ.
- [ ] **Step 2: Run it, verify it fails**`npx vitest run tests/unit/lunch-list-dishes-perm.test.ts`. Expected: FAIL with an `UNAUTHORIZED` TRPCError (APPLICANT blocked by `adminProcedure`).
- [ ] **Step 3: Change the procedure** — in `src/server/routers/lunch.ts:42`, change `listDishes: adminProcedure` to `listDishes: protectedProcedure`. Ensure `protectedProcedure` is imported in that file (it is used elsewhere in the router; if not, add it to the import from `../trpc`).
- [ ] **Step 4: Run it, verify it passes** — same command. Expected: PASS.
- [ ] **Step 5: Commit**`git add -A && git commit -m "fix(lunch): allow non-admins to read dish list (unblocks applicant picker)"`
---
## Task 2: Re-invite-safe confirmation helper (fixes the P2002 dead-end)
**Files:**
- Create: `src/server/services/finalist-enrollment.ts`
- Test: covered indirectly in Task 3; add a focused unit here.
- [ ] **Step 1: Write the failing test** (add to `tests/unit/finalist-enrollment.test.ts`, created here):
```ts
import { afterAll, describe, expect, it } from 'vitest'
import { prisma } from '../setup'
import { createTestProgram, createTestProject, cleanupTestData, uid } from '../helpers'
import { resetOrCreatePendingConfirmation } from '../../src/server/services/finalist-enrollment'
describe('resetOrCreatePendingConfirmation', () => {
const programIds: string[] = []
afterAll(async () => {
for (const id of programIds) {
await prisma.attendingMember.deleteMany({ where: { confirmation: { project: { programId: id } } } })
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId: id } } })
await cleanupTestData(id, [])
}
})
it('creates a fresh PENDING row when none exists', async () => {
const program = await createTestProgram({ name: `reinvite-new-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
const res = await resetOrCreatePendingConfirmation(prisma, {
projectId: project.id, category: 'STARTUP', windowHours: 24,
})
const row = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { id: res.id } })
expect(row.status).toBe('PENDING')
expect(res.alreadyConfirmed).toBe(false)
})
it('resets a DECLINED row to a fresh PENDING (no unique-constraint crash)', async () => {
const program = await createTestProgram({ name: `reinvite-declined-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
await prisma.finalistConfirmation.create({
data: { projectId: project.id, category: 'STARTUP', status: 'DECLINED',
deadline: new Date(Date.now() - 1000), token: `tok_${uid()}`, declinedAt: new Date(),
declineReason: 'busy' },
})
const res = await resetOrCreatePendingConfirmation(prisma, {
projectId: project.id, category: 'STARTUP', windowHours: 24,
})
const row = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { id: res.id } })
expect(row.status).toBe('PENDING')
expect(row.declinedAt).toBeNull()
expect(row.declineReason).toBeNull()
expect(res.alreadyConfirmed).toBe(false)
})
it('is a no-op flagged alreadyConfirmed when row is CONFIRMED', async () => {
const program = await createTestProgram({ name: `reinvite-confirmed-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
await prisma.finalistConfirmation.create({
data: { projectId: project.id, category: 'STARTUP', status: 'CONFIRMED',
deadline: new Date(Date.now() + 1000), token: `tok_${uid()}`, confirmedAt: new Date() },
})
const res = await resetOrCreatePendingConfirmation(prisma, {
projectId: project.id, category: 'STARTUP', windowHours: 24,
})
expect(res.alreadyConfirmed).toBe(true)
})
})
```
- [ ] **Step 2: Run it, verify it fails**`npx vitest run tests/unit/finalist-enrollment.test.ts`. Expected: FAIL (module/function not found).
- [ ] **Step 3: Implement the helper** — create `src/server/services/finalist-enrollment.ts`:
```ts
import type { CompetitionCategory, Prisma, PrismaClient } from '@prisma/client'
import { signFinalistToken } from '@/lib/finalist-token'
type TxClient = PrismaClient | Prisma.TransactionClient
/**
* Re-invite-safe variant of createPendingConfirmation. If a confirmation row
* already exists for the project (projectId is @unique), reset any
* non-CONFIRMED row back to a fresh PENDING with a new token/deadline and
* clear stale attendee rows; report CONFIRMED rows as a no-op so callers can
* skip them. Returns the row id + token + deadline for the email step.
*/
export async function resetOrCreatePendingConfirmation(
prisma: TxClient,
args: { projectId: string; category: CompetitionCategory; windowHours: number },
): Promise<{ id: string; token: string; deadline: Date; alreadyConfirmed: boolean }> {
const deadline = new Date(Date.now() + args.windowHours * 3_600_000)
const existing = await prisma.finalistConfirmation.findUnique({
where: { projectId: args.projectId },
select: { id: true, status: true },
})
if (existing?.status === 'CONFIRMED') {
return { id: existing.id, token: '', deadline, alreadyConfirmed: true }
}
if (existing) {
const token = signFinalistToken({
confirmationId: existing.id,
exp: Math.floor(deadline.getTime() / 1000),
})
// Clear any attendee rows from a prior cycle (cascade-deletes flight/visa/lunch).
await prisma.attendingMember.deleteMany({ where: { confirmationId: existing.id } })
await prisma.finalistConfirmation.update({
where: { id: existing.id },
data: {
category: args.category,
status: 'PENDING',
deadline,
token,
confirmedAt: null,
declinedAt: null,
declineReason: null,
expiredAt: null,
},
})
return { id: existing.id, token, deadline, alreadyConfirmed: false }
}
const id = `cmfc_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`
const token = signFinalistToken({
confirmationId: id,
exp: Math.floor(deadline.getTime() / 1000),
})
await prisma.finalistConfirmation.create({
data: {
id,
projectId: args.projectId,
category: args.category,
status: 'PENDING',
deadline,
token,
},
})
return { id, token, deadline, alreadyConfirmed: false }
}
```
> Confirm `AttendingMember`→`FlightDetail`/`VisaApplication`/`MemberLunchPick` are `onDelete: Cascade` (they are, per schema 2789+); the `deleteMany` then cleans dependents. If `signFinalistToken`'s import path differs, match `src/server/services/finalist-confirmation.ts:2`.
- [ ] **Step 4: Run it, verify it passes** — same command. Expected: PASS (3 tests).
- [ ] **Step 5: Commit**`git add -A && git commit -m "feat(finalist): re-invite-safe confirmation reset helper"`
---
## Task 3: `finalist.enrollFinalists` — the unified entry point
**Files:**
- Modify: `src/server/routers/finalist.ts` (add procedure; import the helper)
- Test: `tests/unit/finalist-enrollment.test.ts` (extend)
**Procedure contract:**
```ts
enrollFinalists: adminProcedure
.input(z.object({
programId: z.string(),
roundId: z.string(), // the LIVE_FINAL round
enrollments: z.array(z.object({
projectId: z.string(),
mode: z.enum(['EMAIL', 'ADMIN_CONFIRM']),
attendingUserIds: z.array(z.string()).optional(), // required for ADMIN_CONFIRM
visaFlags: z.record(z.string(), z.boolean()).optional(),
})).min(1),
}))
.mutation(async ({ ctx, input }) => { /* see steps */ })
```
Behavior per enrollment:
1. Validate the project is in `programId` and read its `competitionCategory`, `defaultAttendeeCap`, team members + LEAD email.
2. **Round membership:** `projectRoundState.createMany({ data: [{ projectId, roundId }], skipDuplicates: true })` (mirror `round.ts:545`).
3. **Confirmation:** `resetOrCreatePendingConfirmation()`. If `alreadyConfirmed`, record `skipped: 'ALREADY_CONFIRMED'` and continue.
4. If `mode === 'EMAIL'`: send `sendFinalistConfirmationEmail(lead.email, lead.name, project.title, deadline, confirmUrl)` inside a try/catch (never throw in the loop — mirror `finalist.ts:213`).
5. If `mode === 'ADMIN_CONFIRM'`: validate `attendingUserIds` (non-empty, ≤ cap, all team members) then run the confirm transaction (attendees + visa rows + lunch picks) exactly as `finalist.ts:492-523`. No email.
6. Audit `FINALIST_ENROLL` with `{ projectId, mode }`.
7. Return `{ enrolled, emailed, adminConfirmed, skipped: [...] }`.
- [ ] **Step 1: Write failing tests** (extend `finalist-enrollment.test.ts`). Build an admin caller via `createCaller(finalistRouter, {…SUPER_ADMIN})`, a program (`defaultAttendeeCap: 3`), a competition with a `LIVE_FINAL` round (`createTestRound(comp.id, { roundType: 'LIVE_FINAL', sortOrder: 99, configJson: { confirmationWindowHours: 24 } })`) and a MENTORING round with a `ProjectRoundState` for the project. Assert:
```ts
// EMAIL mode
it('EMAIL mode: creates PRS in LIVE_FINAL + PENDING confirmation, no attendees', async () => {
// ... call enrollFinalists with one { projectId, mode: 'EMAIL' }
const prs = await prisma.projectRoundState.findFirst({ where: { projectId, roundId: liveFinalId } })
expect(prs).not.toBeNull()
const conf = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { projectId } })
expect(conf.status).toBe('PENDING')
expect(await prisma.attendingMember.count({ where: { confirmationId: conf.id } })).toBe(0)
})
// ADMIN_CONFIRM mode
it('ADMIN_CONFIRM mode: CONFIRMED with attendee + visa + lunch rows', async () => {
// ... call with { projectId, mode: 'ADMIN_CONFIRM', attendingUserIds: [lead.id, member.id], visaFlags: { [member.id]: true } }
const conf = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { projectId } })
expect(conf.status).toBe('CONFIRMED')
expect(await prisma.attendingMember.count({ where: { confirmationId: conf.id } })).toBe(2)
expect(await prisma.visaApplication.count({ where: { attendingMember: { confirmationId: conf.id } } })).toBe(1)
})
// idempotent membership + re-invite
it('re-enrolling a DECLINED project resets it without crashing and keeps one PRS row', async () => {
// pre-create DECLINED confirmation + a PRS; enroll EMAIL again
// expect status PENDING and exactly one PRS row for (projectId, liveFinalId)
})
// ADMIN_CONFIRM over cap rejects
it('ADMIN_CONFIRM rejects when attendees exceed cap', async () => {
await expect(/* 4 attendees with cap 3 */).rejects.toThrow(/cap/i)
})
```
- [ ] **Step 2: Run, verify fail**`npx vitest run tests/unit/finalist-enrollment.test.ts`. Expected: FAIL (`enrollFinalists` not a function).
- [ ] **Step 3: Implement** the procedure in `finalist.ts`. Reuse: import `resetOrCreatePendingConfirmation` from `../services/finalist-enrollment`; reuse `sendFinalistConfirmationEmail`, `ensureLunchPickForAttendingMember`, `logAudit`, `signFinalistToken` already imported. For the ADMIN_CONFIRM transaction body, copy the exact `$transaction` block from `adminConfirm` (`finalist.ts:492-523`). Resolve `windowHours` from the LIVE_FINAL round's `configJson.confirmationWindowHours ?? 24` (mirror `finalist.ts:145`). Build `confirmUrl` from `NEXTAUTH_URL` (mirror `finalist.ts:191-204`).
- [ ] **Step 4: Run, verify pass** — same command. Expected: PASS.
- [ ] **Step 5: Refactor for DRY (optional within budget)** — extract the shared confirm transaction into `confirmAttendanceInTx(tx, { confirmationId, attendingUserIds, visaFlags })` in `finalist-enrollment.ts` and call it from both `adminConfirm` and `enrollFinalists`. Re-run `npx vitest run tests/unit/finalist-admin-confirm.test.ts tests/unit/finalist-enrollment.test.ts`. Expected: PASS both.
- [ ] **Step 6: Commit**`git commit -am "feat(finalist): unified enrollFinalists (round membership + confirmation + email/admin-confirm)"`
---
## Task 4: `finalist.unenroll` — reverse membership + confirmation
**Files:**
- Modify: `src/server/routers/finalist.ts`
- Test: `tests/unit/finalist-unenroll.test.ts`
**Contract:** `unenroll({ projectId, roundId })`
1. Delete the `FinalistConfirmation` for the project (cascade removes AttendingMember/FlightDetail/VisaApplication/lunch picks).
2. Delete the LIVE_FINAL `ProjectRoundState` (`deleteMany({ where: { projectId, roundId } })`).
3. Audit `FINALIST_UNENROLL`.
4. Return `{ ok: true }`. (Mentor assignments are tied to the MENTORING round and are intentionally left untouched.)
- [ ] **Step 1: Failing test** — enroll (ADMIN_CONFIRM) a project, then `unenroll`; assert no confirmation, no attendees, no LIVE_FINAL PRS, and an audit row exists.
- [ ] **Step 2: Run, verify fail.**
- [ ] **Step 3: Implement** the procedure.
- [ ] **Step 4: Run, verify pass**`npx vitest run tests/unit/finalist-unenroll.test.ts`.
- [ ] **Step 5: Commit**`git commit -am "feat(finalist): unenroll reverses round membership + confirmation"`
---
## Task 5: `finalist.listEnrollmentCandidates` — data for the UI
**Files:**
- Modify: `src/server/routers/finalist.ts`
- Test: `tests/unit/finalist-enrollment.test.ts` (extend)
**Contract:** `listEnrollmentCandidates({ programId })` resolves the program's competition, finds the MENTORING round and the LIVE_FINAL round, and returns:
```ts
{
liveFinalRoundId: string | null,
attendeeCap: number,
categories: Array<{
category: CompetitionCategory,
quota: number | null,
confirmedCount: number,
pendingCount: number,
candidates: Array<{
projectId: string,
title: string,
teamName: string | null,
country: string | null,
inLiveFinal: boolean, // has a LIVE_FINAL ProjectRoundState
confirmationStatus: FinalistConfirmationStatus | null,
teamMembers: Array<{ userId: string, name: string | null, role: 'LEAD' | 'MEMBER', email: string }>,
}>,
}>,
}
```
Source candidates from `ProjectRoundState` in the MENTORING round (mirror `round.ts:335`), join `project.finalistConfirmation.status`, a `ProjectRoundState` existence check against the LIVE_FINAL round for `inLiveFinal`, and `project.teamMembers` (for the ADMIN_CONFIRM picker). Group by `competitionCategory`; merge per-category `FinalistSlotQuota` + confirmed/pending counts (reuse the count logic from `finalist.listCategoryCounts`).
- [ ] **Step 1: Failing test** — set up a MENTORING round with one STARTUP project (PRS), assert the project appears under the STARTUP category with `inLiveFinal: false`, `confirmationStatus: null`, and its team members listed.
- [ ] **Step 2: Run, verify fail.**
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run, verify pass.**
- [ ] **Step 5: Commit**`git commit -am "feat(finalist): listEnrollmentCandidates query for enrollment UI"`
---
## Task 6: Enrollment UI card on the LIVE_FINAL round page
**Files:**
- Create: `src/components/admin/grand-finale/finalist-enrollment-card.tsx`
- Create: `src/components/admin/grand-finale/enroll-attendees-dialog.tsx`
- Modify: `src/app/(admin)/admin/rounds/[roundId]/page.tsx` (render the card in the grand-finale block, ~line 1528, above `FinalistSlotsCard`)
**Card UX (follow the visual pattern of `finalist-slots-card.tsx` / `waitlist-card.tsx`):**
- `trpc.finalist.listEnrollmentCandidates.useQuery({ programId })`. Loading → Skeleton; empty → "No mentoring-round teams to enroll yet."
- Group candidates by category with a header showing `confirmed/quota` (e.g. "Startup — 2/8 confirmed, 1 pending").
- Each candidate row: checkbox, title + teamName + country, and a status badge (`Not enrolled` / `In round` / `Pending` / `Confirmed` / `Declined`). Confirmed/declined rows show an **Un-enroll** button instead of a checkbox.
- Per selected row, a small mode toggle: **Email team** (default) or **Set attendees now**. Choosing "Set attendees now" opens `EnrollAttendeesDialog` (a ≤cap member multi-select with per-member "needs visa" checkbox) and stashes the chosen `attendingUserIds`/`visaFlags` on that row.
- Footer: **Enroll selected** and **Enroll all eligible** (eligible = not already CONFIRMED). Calls `trpc.finalist.enrollFinalists.useMutation`, then `utils.finalist.listEnrollmentCandidates.invalidate()` + `utils.logistics.listConfirmations.invalidate()`. Toast the `{ enrolled, emailed, adminConfirmed, skipped }` summary.
- Un-enroll buttons call `trpc.finalist.unenroll` behind an `AlertDialog` confirm ("This removes them from the Grand Final round and deletes their attendance record. Continue?").
> Use shadcn `AlertDialog` for confirms (no native `confirm()`), per house style and the no-keyboard-shortcuts preference (visible affordances only).
- [ ] **Step 1:** Build `enroll-attendees-dialog.tsx` (props: `members`, `cap`, `onConfirm(attendingUserIds, visaFlags)`).
- [ ] **Step 2:** Build `finalist-enrollment-card.tsx` per the UX above.
- [ ] **Step 3:** Render `<FinalistEnrollmentCard programId={programId} roundId={round.id} />` in the LIVE_FINAL grand-finale block in the round page.
- [ ] **Step 4: Typecheck**`npm run typecheck`. Expected: clean.
- [ ] **Step 5: Commit**`git commit -am "feat(grand-finale): finalist enrollment card on LIVE_FINAL round page"`
---
## Task 7: Waitlist populate UI + confirmations-tab dead-end fixes
**Files:**
- Modify: `src/components/admin/grand-finale/waitlist-card.tsx`
- Modify: `src/components/admin/logistics/confirmations-tab.tsx`
- [ ] **Step 1: Add-to-waitlist control** in `waitlist-card.tsx` — a category + project picker (project options = enrollment candidates not already confirmed/waitlisted) that calls the existing `trpc.finalist.addToWaitlist` mutation (which had no UI). Invalidate `listWaitlist` on success. Confirm `addToWaitlist`'s exact input shape at `finalist.ts:629` before wiring.
- [ ] **Step 2: Fix the dead-end copy** in `confirmations-tab.tsx:127` — replace "Use the grand-finale round page to send confirmations." with "Enroll finalists from the Grand Final round's Overview tab to start confirmations." (Or, if scope allows, render an inline `<Link>` to the round page.)
- [ ] **Step 3: Add row actions** to `confirmations-tab.tsx` (currently only PENDING rows show Confirm/Decline; all others show "—"):
- CONFIRMED rows → **Un-confirm** button calling existing `trpc.finalist.unconfirm` (behind `AlertDialog`).
- DECLINED / EXPIRED rows → **Re-invite** button calling `trpc.finalist.enrollFinalists` with `{ projectId, mode: 'EMAIL', roundId: liveFinalRoundId }` (re-invite-safe via Task 2). Needs the LIVE_FINAL roundId — fetch via `listEnrollmentCandidates` or add it to `listConfirmations`' payload.
- [ ] **Step 4: Typecheck**`npm run typecheck`. Expected: clean.
- [ ] **Step 5: Commit**`git commit -am "feat(logistics): waitlist populate UI + confirmations-tab un-confirm/re-invite actions"`
---
## Task 8: Full verification
- [ ] **Step 1: Run the whole finalist/lunch/logistics suite**`npx vitest run tests/unit/finalist-enrollment.test.ts tests/unit/finalist-unenroll.test.ts tests/unit/lunch-list-dishes-perm.test.ts tests/unit/finalist-admin-confirm.test.ts tests/unit/finalist-quotas.test.ts tests/unit/finalist-confirmation.test.ts`. Expected: all PASS (regression check on the refactor).
- [ ] **Step 2: Typecheck + build**`npm run typecheck && npm run build`. Expected: clean (build is required before any push, per CLAUDE.md).
- [ ] **Step 3: Dev smoke (Playwright, server already on :3001 as super-admin)** — verify the end-to-end the audit proved was impossible:
1. Mentoring round (R6) → ensure a project has a `ProjectRoundState` (advance one in if needed).
2. Grand Final round (R7) Overview → the new Enrollment card lists it; enroll it in EMAIL mode.
3. `/admin/logistics` → Confirmations tab now shows a PENDING row (no longer the dead-end empty state).
4. Enroll a second team in ADMIN_CONFIRM mode with 2 attendees → Confirmations shows CONFIRMED with attendee count 2; Travel/Visas tabs now list those attendees.
5. Confirm the LIVE_FINAL round's Projects tab now shows the enrolled teams (jury can see them).
- [ ] **Step 4: Final commit / branch** — ensure work is on a feature branch (not `main`); summarize for review.
---
## Self-review notes (coverage vs. the 4 BLOCKERs)
- BLOCKER 1 (no select-finalists UI) → Tasks 3 + 6 (`enrollFinalists` + enrollment card).
- BLOCKER 2 (no waitlist populate UI) → Task 7 step 1.
- BLOCKER 3 (lunch picker broken) → Task 1.
- BLOCKER 4 (re-invite crash) → Task 2 (`resetOrCreatePendingConfirmation`), surfaced via Task 7 step 3 (Re-invite action).
- Unified enroll + both attendee modes (locked decisions) → Task 3.
- Un-confirm dead-end (HIGH) → Task 7 step 3.
**Out of scope for Wave 1 (deferred to later waves):** all the new transactional emails/notifications and reminders (Wave 2), the Email Templates tab (Wave 3), team-facing "My Logistics" + timezone/validation/export UX fixes (Wave 4). The only email this wave sends is the existing `sendFinalistConfirmationEmail` (now reachable via EMAIL-mode enroll and Re-invite).

View File

@@ -0,0 +1,186 @@
# Wave 2 — Close the Logistics Email/Notification Void
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Make logistics actually *communicate*. Today logistics fires exactly one automatic email (`sendFinalistConfirmationEmail`) and creates zero in-app notifications. This wave adds confirmation reminders, admin alerts, withdrawal emails, attendee travel/visa emails, and fixes the lunch reminder/recap comms — all routed through the existing notification pipeline so admins keep on/off control.
**Architecture:** Reuse the established comms pipeline (decision: reuse, not a new system). `createNotification({ userId, type, title, message, linkUrl, metadata })` writes an in-app row AND conditionally sends a branded email when a `NotificationEmailSetting` row exists for that type with `sendEmail=true` and the user's `notificationPreference` allows email. New notification types get registered in `NotificationTypes`, optionally given a custom branded template in `NOTIFICATION_EMAIL_TEMPLATES` (team/attendee-facing) or left to fall back to the generic branded template (admin alerts), and seeded in `prisma/seed-notification-settings.ts` (which runs on every deploy + dev).
**Tech Stack:** Next.js 15, tRPC 11, Prisma 6, Vitest 4. One small schema migration (`reminderSentAt`).
**Key infra facts (verified 2026-06-04):**
- `createNotification(params)``src/server/services/in-app-notification.ts:185`. Email leg gated by `NotificationEmailSetting` (no row OR `sendEmail=false` → no email) + user `notificationPreference ∈ {EMAIL, BOTH}`.
- Helpers: `notifyAdmins({type,title,message,linkUrl,metadata})` (`:324`), `notifyProjectTeam({projectId,...})` (`:374`), `createBulkNotifications` (`:263`).
- Email body for a type comes from `NOTIFICATION_EMAIL_TEMPLATES[type]` (`src/lib/email.ts:2196`); missing entry → generic branded fallback `getNotificationEmailTemplate` (`:2635`). `sendStyledNotificationEmail` (`:2400`) is the sender.
- Settings seeded idempotently in `prisma/seed-notification-settings.ts` (row shape `{ notificationType, category, label, description, sendEmail }`), run on every container start via `docker/docker-entrypoint.sh:72`.
- Dead stub types already in registry (do NOT reuse; add explicit new ones): `MENTEE_FINALIST`, `EVENT_INVITATION`, `FINALISTS_ANNOUNCED`.
- Recipients: admins via `roles: { has: 'SUPER_ADMIN' }` / `'PROGRAM_ADMIN'` + `status:'ACTIVE'`; team lead via `teamMembers where role:'LEAD'`; attendee email via `AttendingMember.user`.
- `FinalistConfirmation` has NO `reminderSentAt` yet (Task 1 adds it). Lunch cron attendee-filter bug confirmed (Task 7).
**New notification type constants (add all to `NotificationTypes`):**
| Constant | Audience | Email template | Seed `sendEmail` |
|---|---|---|---|
| `FINALIST_CONFIRMED` | admins | fallback | true |
| `FINALIST_DECLINED` | admins | fallback | true |
| `FINALIST_EXPIRED` | admins | fallback | true |
| `FINALIST_WAITLIST_PROMOTED` | admins | fallback | true |
| `FINALIST_REMINDER` | team lead | custom | true |
| `FINALIST_WITHDRAWN` | team | custom | true |
| `TRAVEL_CONFIRMED` | attendee | custom | true |
| `VISA_STATUS_UPDATE` | attendee | custom | true |
---
## File structure
**Create:**
- `prisma/migrations/<ts>_add_finalist_reminder_sent_at/migration.sql`
- `tests/unit/finalist-comms.test.ts` — admin alerts + withdrawal notifications fire
- `tests/unit/finalist-reminders.test.ts` — reminder cron sends + stamps + idempotent
- `tests/unit/logistics-comms.test.ts` — flight-confirmed + visa-status emails fire
- `tests/unit/lunch-reminder-filter.test.ts` — cron picks up attendees with no pick row
**Modify:**
- `prisma/schema.prisma``FinalistConfirmation.reminderSentAt DateTime?`
- `src/server/services/in-app-notification.ts` — add 8 `NotificationTypes` constants (+ icons/priorities optional)
- `src/lib/email.ts` — 4 custom templates + register in `NOTIFICATION_EMAIL_TEMPLATES`
- `prisma/seed-notification-settings.ts` — 8 new setting rows (category `logistics`)
- `src/server/routers/finalist.ts` — admin alerts in `confirm`/`decline`/`adminDecline`/`manualPromote`; withdrawal in `adminDecline`/`unconfirm`/`unenroll`
- `src/server/services/finalist-confirmation.ts` — admin alert in `expirePendingPastDeadline` + `promoteNextWaitlistEntry`; new `sendDueConfirmationReminders`
- `src/app/api/cron/finalist-confirmations/route.ts` — call `sendDueConfirmationReminders`
- `src/server/routers/logistics.ts` — emails in `setFlightStatus`(→CONFIRMED) + `updateVisaApplication`(status transitions)
- `src/app/api/cron/lunch-reminders/route.ts` — fix attendee OR-filter
- `src/server/routers/lunch.ts` — surface recap send failure; add `sendReminders` mutation
- `src/components/admin/logistics/lunch-recap-actions.tsx` — "Send reminders now" button
---
## Task 1: Schema — `reminderSentAt`
**Files:** `prisma/schema.prisma`, migration.
- [ ] **Step 1:** Add to `FinalistConfirmation`: `reminderSentAt DateTime?` (near `confirmedAt`/`expiredAt`).
- [ ] **Step 2:** `npx prisma migrate dev --name add_finalist_reminder_sent_at`. Expected: migration created + applied, client regenerated.
- [ ] **Step 3:** `npm run typecheck` — clean.
- [ ] **Step 4: Commit**`git add prisma/ && git commit -m "feat(finalist): add reminderSentAt for confirmation reminders"`
---
## Task 2: Notification types, templates, and settings
**Files:** `src/server/services/in-app-notification.ts`, `src/lib/email.ts`, `prisma/seed-notification-settings.ts`.
- [ ] **Step 1:** Add the 8 constants to the `NotificationTypes` object (`in-app-notification.ts:15`), grouped under a `// Logistics` comment, e.g. `FINALIST_CONFIRMED: 'FINALIST_CONFIRMED',` … through `VISA_STATUS_UPDATE: 'VISA_STATUS_UPDATE',`. Optionally add icons/priorities (e.g. `FINALIST_EXPIRED: 'urgent'`, `VISA_STATUS_UPDATE: 'high'`).
- [ ] **Step 2:** Add 4 custom branded templates in `src/lib/email.ts` (mirror an existing private template that uses `getEmailWrapper` + `sectionTitle`/`paragraph`/`ctaButton`/`infoBox`). Each returns `{ subject, html, text }`:
- `getFinalistReminderTemplate(name, projectTitle, deadline, confirmUrl)` — "Reminder: confirm your grand-finale attendance by <formatted deadline>". Format the deadline human-readably (`toLocaleString('en-GB', { timeZone: 'Europe/Paris', dateStyle:'full', timeStyle:'short' })`).
- `getFinalistWithdrawnTemplate(name, projectTitle, reason?)` — "Your grand-finale slot has been withdrawn".
- `getTravelConfirmedTemplate(name, projectTitle, flight, hotel?)` — itinerary (arrival/departure flight no + airport + formatted times) and, if `hotel` provided, hotel name/address/link.
- `getVisaStatusTemplate(name, projectTitle, status, note?)` — status-specific copy for `INVITATION_SENT` / `APPOINTMENT_BOOKED` / `GRANTED` / `DENIED`.
Then register them in `NOTIFICATION_EMAIL_TEMPLATES` (`:2196`) keyed by the type string, reading fields from `ctx.metadata` (e.g. `FINALIST_REMINDER: (ctx) => getFinalistReminderTemplate(ctx.name||'', ctx.metadata?.projectTitle as string, new Date(ctx.metadata?.deadline as string), ctx.linkUrl||'')`). Admin-alert types are intentionally NOT registered (they use the generic fallback).
- [ ] **Step 3:** Add 8 rows to `NOTIFICATION_EMAIL_SETTINGS` in `prisma/seed-notification-settings.ts` (category `'logistics'`), all `sendEmail: true`, with clear `label`/`description`.
- [ ] **Step 4:** Apply to the dev DB: `npx tsx prisma/seed-notification-settings.ts`. Expected: upserts succeed (idempotent).
- [ ] **Step 5:** `npm run typecheck` — clean.
- [ ] **Step 6: Commit**`git commit -am "feat(comms): logistics notification types, templates, and email settings"`
---
## Task 3: Admin alerts on confirmation lifecycle
**Files:** `src/server/routers/finalist.ts`, `src/server/services/finalist-confirmation.ts`; test `tests/unit/finalist-comms.test.ts`.
For each event, call `notifyAdmins({ type, title, message, linkUrl: '/admin/logistics', metadata: { projectId, projectTitle, category } })`. Wrap in try/catch — comms must never throw inside the mutation (mirror the round-notification rule in CLAUDE.md).
- Team confirms (`finalist.confirm`, after the transaction) → `FINALIST_CONFIRMED`.
- Team declines (`finalist.decline`) and admin declines (`finalist.adminDecline`) → `FINALIST_DECLINED`.
- Cron expiry (`expirePendingPastDeadline`, per expired row) → `FINALIST_EXPIRED`.
- Waitlist promotion (`promoteNextWaitlistEntry` and `manualPromote`) → `FINALIST_WAITLIST_PROMOTED`.
- [ ] **Step 1: Failing tests** — for `confirm`, `decline`, and `expirePendingPastDeadline`, assert an `InAppNotification` row with the right `type` is created for an admin user after the action (set up a `SUPER_ADMIN` with `status:'ACTIVE'`). Use the existing finalist test setup patterns.
- [ ] **Step 2:** Run → fail.
- [ ] **Step 3:** Implement the `notifyAdmins` calls. Import from `../services/in-app-notification` (note: `finalist-confirmation.ts` is a service — import directly).
- [ ] **Step 4:** Run → pass; re-run `tests/unit/finalist-confirmation.test.ts` for no regressions.
- [ ] **Step 5: Commit**`git commit -am "feat(finalist): admin alerts on confirm/decline/expire/promote"`
---
## Task 4: Withdrawal emails to teams
**Files:** `src/server/routers/finalist.ts`; test `tests/unit/finalist-comms.test.ts`.
When a team's slot is withdrawn by an admin, notify the team lead with `FINALIST_WITHDRAWN` (in-app + email). Events: `adminDecline`, `unconfirm` (CONFIRMED→SUPERSEDED), and `unenroll` when a CONFIRMED confirmation existed.
- [ ] **Step 1: Failing test** — after `adminDecline`, assert a `FINALIST_WITHDRAWN` `InAppNotification` exists for the team lead's userId.
- [ ] **Step 2:** Run → fail.
- [ ] **Step 3:** Implement: resolve the lead (`teamMembers where role:'LEAD'`), `createNotification({ userId: lead.userId, type: NotificationTypes.FINALIST_WITHDRAWN, title:'Grand finale slot withdrawn', message:`Your team "${title}" is no longer a confirmed finalist.${reason? ' Reason: '+reason : ''}`, linkUrl:'/applicant', metadata:{ projectTitle: title, reason } })` in try/catch. In `unenroll`, capture whether a CONFIRMED row existed BEFORE the delete, and only notify then.
- [ ] **Step 4:** Run → pass; re-run `finalist-unconfirm`, `finalist-unenroll`, `finalist-admin-confirm` suites.
- [ ] **Step 5: Commit**`git commit -am "feat(finalist): withdrawal notification to team on decline/unconfirm/unenroll"`
---
## Task 5: Confirmation reminder cron
**Files:** `src/server/services/finalist-confirmation.ts`, `src/app/api/cron/finalist-confirmations/route.ts`; test `tests/unit/finalist-reminders.test.ts`.
Add `sendDueConfirmationReminders(prisma): Promise<{ remindersSent: number }>`:
- Resolve a reminder lead time: read each program's LIVE_FINAL round `configJson.reminderHoursBeforeDeadline` (default 12).
- Query `FinalistConfirmation` where `status:'PENDING' AND reminderSentAt IS NULL AND deadline > now AND deadline <= now + reminderHours`. (Simplest: load all PENDING with `reminderSentAt:null AND deadline>now`, then filter by each program's lead time.)
- For each: send via `createNotification({ userId: lead.userId, type: FINALIST_REMINDER, title, message, linkUrl: confirmUrl (the public token URL — build like selectFinalists), metadata:{ projectTitle, deadline } })`, then `update reminderSentAt = now`. Best-effort per row (try/catch).
- Reset `reminderSentAt` is NOT needed (deadlines don't move here; if re-invited, `resetOrCreatePendingConfirmation` should also clear `reminderSentAt` — ADD that field reset in the Wave 1 helper).
- [ ] **Step 1:** In `resetOrCreatePendingConfirmation` (`src/server/services/finalist-enrollment.ts`), add `reminderSentAt: null` to the reset `update` data (so re-invited teams get a fresh reminder window).
- [ ] **Step 2: Failing test** — create a PENDING confirmation with `deadline = now + 6h` and `reminderSentAt:null`, a LIVE_FINAL round with `reminderHoursBeforeDeadline: 12`, a lead user; call `sendDueConfirmationReminders`; assert `remindersSent===1`, a `FINALIST_REMINDER` notification exists for the lead, and `reminderSentAt` is now set. Second call → `remindersSent===0` (idempotent).
- [ ] **Step 3:** Run → fail.
- [ ] **Step 4:** Implement `sendDueConfirmationReminders` and call it from the cron route (before or after `expirePendingPastDeadline`).
- [ ] **Step 5:** Run → pass.
- [ ] **Step 6: Commit**`git commit -am "feat(finalist): deadline reminder emails via cron"`
---
## Task 6: Travel + visa attendee emails
**Files:** `src/server/routers/logistics.ts`; test `tests/unit/logistics-comms.test.ts`.
- `setFlightStatus` → when set to `CONFIRMED`: load the attendee's user + the program hotel; `createNotification({ userId: attendee.userId, type: TRAVEL_CONFIRMED, ..., metadata:{ projectTitle, arrival/departure fields, hotel } })`. (No email when set back to PENDING.)
- `updateVisaApplication` → when `input.status` changes to one of `INVITATION_SENT|APPOINTMENT_BOOKED|GRANTED|DENIED` (and differs from `existing.status`): `createNotification({ userId: attendee.userId, type: VISA_STATUS_UPDATE, ..., metadata:{ projectTitle, status, note } })`. Gate on nothing (visa outcomes are always relevant); include the admin `notes` only if appropriate — default: don't leak internal notes, send status-only copy.
- [ ] **Step 1: Failing tests** — set a flight to CONFIRMED → assert `TRAVEL_CONFIRMED` notification for the attendee; update a visa to `GRANTED` → assert `VISA_STATUS_UPDATE` notification. (Reuse `logistics-flight.test.ts` / `visa-admin.test.ts` setup patterns.)
- [ ] **Step 2:** Run → fail.
- [ ] **Step 3:** Implement. For `setFlightStatus`, the procedure currently only has `flightDetailId`; join to `attendingMember.user` + program hotel. For `updateVisaApplication`, the existing row read already gives `attendingMember` — extend the select to include `user` + project title.
- [ ] **Step 4:** Run → pass; re-run `logistics-flight`, `visa-admin`, `visa-application-lifecycle`.
- [ ] **Step 5: Commit**`git commit -am "feat(logistics): travel-confirmed + visa-status emails to attendees"`
---
## Task 7: Lunch reminder/recap fixes
**Files:** `src/app/api/cron/lunch-reminders/route.ts`, `src/server/routers/lunch.ts`, `src/components/admin/logistics/lunch-recap-actions.tsx`; test `tests/unit/lunch-reminder-filter.test.ts`.
- [ ] **Step 1: Failing test** — a CONFIRMED attendee with NO `MemberLunchPick` row should be counted as needing a reminder. Assert the cron's selection query (extract it into a small exported helper `selectUnpickedAttendees(prisma, event)` for testability) returns that attendee.
- [ ] **Step 2:** Run → fail (current `is` filter misses null-relation rows).
- [ ] **Step 3:** Fix the filter to `OR: [{ lunchPick: { is: null } }, { lunchPick: { is: { pickedAt: null } } }]` (cron `route.ts:44`).
- [ ] **Step 4:** Recap failure surfacing (`lunch.ts:345`): only stamp `recapSentAt` + audit `LUNCH_RECAP_SENT` if `sendLunchRecapEmail` resolved; on failure, re-throw (or return `{ ok:false, error }`) so the admin sees a failure toast. Update `lunch-recap-actions.tsx` to show the error.
- [ ] **Step 5:** Add `lunch.sendReminders` mutation (admin) that runs the same selection + `sendLunchReminderEmail` loop as the cron for a given `lunchEventId`, returns `{ sent }`; add a "Send reminders now" button to `lunch-recap-actions.tsx` (behind a confirm).
- [ ] **Step 6:** Run tests → pass; `npm run typecheck` — clean.
- [ ] **Step 7: Commit**`git commit -am "fix(lunch): reminder filter, recap failure surfacing, manual send-reminders"`
---
## Task 8: Full verification
- [ ] **Step 1:** `npx vitest run` — full suite green (target: prior 256 + new tests).
- [ ] **Step 2:** `npm run typecheck` — clean.
- [ ] **Step 3:** Stop the dev server, `rm -rf .next`, `npm run build` — clean (don't build while dev server runs).
- [ ] **Step 4:** Restart dev on :3001; `npx tsx prisma/seed-notification-settings.ts` to ensure settings exist. Dev smoke: enroll a team (ADMIN_CONFIRM) → set their flight CONFIRMED in Travel tab → set a visa GRANTED in Visas tab → confirm an `InAppNotification` row exists for the attendee (query DB) for `TRAVEL_CONFIRMED` and `VISA_STATUS_UPDATE`. Decline a PENDING team → confirm admin `FINALIST_DECLINED` + team `FINALIST_WITHDRAWN`. Clean up (unenroll).
- [ ] **Step 5:** Summarize for review.
---
## Notes
- All comms calls are best-effort (try/catch, never throw inside a mutation/cron) — consistent with CLAUDE.md "round notifications never throw".
- Email sending in dev uses `SMTP_HOST=localhost` (`.env.local`) → sends fail silently and are swallowed; tests assert on the `InAppNotification` row, not on actual delivery.
- Prod gets the new `NotificationEmailSetting` rows automatically via `docker-entrypoint.sh` running `seed-notification-settings.ts` on deploy.
- Deferred to later waves: Email Templates admin tab (Wave 3), team-facing "My Logistics" (Wave 4).

View File

@@ -0,0 +1,113 @@
# Wave 3 — Enable the "Email Templates" tab (logistics hub)
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
**Goal:** Turn the disabled "Email Templates (soon)" tab in `/admin/logistics` into a working surface where admins can, for every logistics email, toggle it on/off, customize the subject, **preview** the rendered email, and send a test to themselves — reusing the existing notification-settings infra.
**Architecture:** Reuse `notification.getEmailSettings` / `updateEmailSetting` / `sendTestEmail` (already built) scoped to the `logistics` category (the 8 types seeded in Wave 2). The only new server capability is **preview without sending**: extract a `renderNotificationEmail(...)` from `sendStyledNotificationEmail` and expose a `notification.previewEmailTemplate({ notificationType })` query returning `{ subject, html }`. UI reuses the existing `EmailPreviewDialog` (with `previewOnly`).
**Tech Stack:** Next.js 15, tRPC 11, shadcn/ui. No schema change.
**Key facts (verified 2026-06-04):**
- `notification.getEmailSettings` (`src/server/routers/notification.ts:140`) returns all `NotificationEmailSetting` rows (incl. our 8 `category:'logistics'` rows).
- `notification.updateEmailSetting` (`:152`) accepts `{ notificationType, sendEmail, emailSubject?, emailTemplate? }`.
- `notification.sendTestEmail` (`:243`) renders via `sendStyledNotificationEmail` using a per-type `sampleData` map (which currently has NO logistics entries → previews/tests render with template fallbacks).
- `sendStyledNotificationEmail` (`src/lib/email.ts:2400`) looks up `NOTIFICATION_EMAIL_TEMPLATES[type]`, else falls back to `getNotificationEmailTemplate`. Our 4 custom templates are registered; the 4 admin-alert types use the fallback.
- `EmailPreviewDialog` (`src/components/admin/round/email-preview-dialog.tsx`) props: `{ open, onOpenChange, title, description, recipientCount, previewHtml, isPreviewLoading, onSend, isSending, showCustomMessage?, onRefreshPreview?, previewOnly? }`.
- The existing global form `src/components/settings/notification-settings-form.tsx` renders categories team/jury/mentor/observer/admin — NOT `logistics`.
---
## Task 1: Server — render-without-send + preview query + logistics sample data
**Files:** `src/lib/email.ts`, `src/server/routers/notification.ts`; test `tests/unit/notification-preview.test.ts`.
- [ ] **Step 1:** In `src/lib/email.ts`, extract the template-resolution logic of `sendStyledNotificationEmail` into an exported pure function:
```ts
export function renderNotificationEmail(
name: string,
type: string,
context: NotificationEmailContext,
subjectOverride?: string,
): EmailTemplate {
const generator = NOTIFICATION_EMAIL_TEMPLATES[type]
const template = generator
? generator({ ...context, name })
: getNotificationEmailTemplate(name, subjectOverride || context.title, context.message, ensureAbsoluteUrl(context.linkUrl))
return subjectOverride ? { ...template, subject: subjectOverride } : template
}
```
Then refactor `sendStyledNotificationEmail` to call `renderNotificationEmail(...)` and `sendEmail(...)` the result (keep its existing signature/behavior identical — verify by re-running any notification email tests).
- [ ] **Step 2:** In `src/server/routers/notification.ts`, hoist the `sampleData` map (currently inside `sendTestEmail`) to a module-level `const NOTIFICATION_SAMPLE_DATA` and ADD logistics entries so previews/tests are realistic:
```ts
FINALIST_CONFIRMED: { projectTitle: 'Ocean Cleanup Initiative', category: 'STARTUP' },
FINALIST_DECLINED: { projectTitle: 'Ocean Cleanup Initiative', category: 'STARTUP' },
FINALIST_EXPIRED: { projectTitle: 'Ocean Cleanup Initiative', category: 'STARTUP' },
FINALIST_WAITLIST_PROMOTED:{ projectTitle: 'Reef Guardians', category: 'STARTUP' },
FINALIST_REMINDER: { projectTitle: 'Ocean Cleanup Initiative', deadline: new Date(Date.now()+86_400_000).toISOString() },
FINALIST_WITHDRAWN: { projectTitle: 'Ocean Cleanup Initiative', reason: 'Schedule conflict' },
TRAVEL_CONFIRMED: { projectTitle: 'Ocean Cleanup Initiative', arrivalAt: new Date(Date.now()+5*86_400_000).toISOString(), arrivalFlightNumber: 'AF1234', arrivalAirport: 'NCE', departureAt: new Date(Date.now()+7*86_400_000).toISOString(), departureFlightNumber: 'AF1235', departureAirport: 'NCE', hotel: { name: 'Hotel de Paris', address: 'Place du Casino, Monaco', link: 'https://example.com' } },
VISA_STATUS_UPDATE: { projectTitle: 'Ocean Cleanup Initiative', status: 'GRANTED' },
```
Have `sendTestEmail` use `NOTIFICATION_SAMPLE_DATA` (behavior unchanged otherwise).
- [ ] **Step 3:** Add a `previewEmailTemplate` adminProcedure query:
```ts
previewEmailTemplate: adminProcedure
.input(z.object({ notificationType: z.string() }))
.query(async ({ ctx, input }) => {
const setting = await ctx.prisma.notificationEmailSetting.findUnique({ where: { notificationType: input.notificationType } })
const label = setting?.label || input.notificationType
const metadata = NOTIFICATION_SAMPLE_DATA[input.notificationType] || {}
const rendered = renderNotificationEmail(ctx.user.name || 'Admin', input.notificationType, {
title: label,
message: `Preview of the "${label}" email.`,
linkUrl: `${process.env.NEXTAUTH_URL || ''}/applicant`,
linkLabel: 'Open',
metadata,
}, setting?.emailSubject || undefined)
return { subject: rendered.subject, html: rendered.html, hasStyledTemplate: input.notificationType in NOTIFICATION_EMAIL_TEMPLATES }
}),
```
Import `renderNotificationEmail` from `@/lib/email`.
- [ ] **Step 4: Test** (`tests/unit/notification-preview.test.ts`): call `notification.previewEmailTemplate({ notificationType: 'VISA_STATUS_UPDATE' })` via an admin caller; assert `html` contains a recognizable string (e.g. 'visa' or 'Grand Finale') and `subject` is non-empty. Also test a fallback type (`FINALIST_EXPIRED`) returns non-empty `html`. (Pattern: `createCaller(notificationRouter, {SUPER_ADMIN})`.)
- [ ] **Step 5:** `npx vitest run tests/unit/notification-preview.test.ts` → pass. `npm run typecheck` → clean.
- [ ] **Step 6: Commit**`git commit -am "feat(notifications): renderNotificationEmail + previewEmailTemplate + logistics sample data"`
---
## Task 2: UI — logistics Email Templates tab + show logistics in global settings
**Files:** create `src/components/admin/logistics/email-templates-tab.tsx`; modify `src/components/settings/notification-settings-form.tsx`.
- [ ] **Step 1:** Add `logistics: { label: 'Logistics', icon: Plane }` to the `CATEGORIES` map in `notification-settings-form.tsx` (import `Plane` from lucide-react) so logistics settings are also manageable on the global settings page.
- [ ] **Step 2:** Build `EmailTemplatesTab` (`src/components/admin/logistics/email-templates-tab.tsx`), `'use client'`, mirroring `NotificationSettingsForm`'s structure but: (a) filter `trpc.notification.getEmailSettings` to `category === 'logistics'`; (b) per row show the toggle (`updateEmailSetting`), a **subject** input (debounced `onBlur``updateEmailSetting({ notificationType, sendEmail, emailSubject })`), a **Test** button (`sendTestEmail`), and a **Preview** button; (c) Preview opens `EmailPreviewDialog` with `previewOnly`, fetching `trpc.notification.previewEmailTemplate({ notificationType })` (lazy, `enabled: !!previewType`) and passing its `html` to `previewHtml`. Loading → Skeleton; empty → "No logistics email types found — run the notification settings seed." Use sonner toasts + `trpc.useUtils()` invalidation.
- [ ] **Step 3:** `npm run typecheck` → clean.
- [ ] **Step 4: Commit**`git commit -am "feat(logistics): Email Templates tab (toggle/subject/preview/test) + logistics in global settings"`
---
## Task 3: Enable the tab in the logistics page
**Files:** `src/app/(admin)/admin/logistics/page.tsx`.
- [ ] **Step 1:** Remove `disabled` + the "(soon)" span from the `email-templates` `TabsTrigger`; import and add `<TabsContent value="email-templates"><EmailTemplatesTab programId={programId} /></TabsContent>` (the tab doesn't strictly need programId — settings are global — but pass it for consistency / future scoping; if unused, omit the prop).
- [ ] **Step 2:** `npm run typecheck` → clean.
- [ ] **Step 3: Commit**`git commit -am "feat(logistics): enable Email Templates tab"`
---
## Task 4: Verify
- [ ] **Step 1:** `npx vitest run` — full suite green.
- [ ] **Step 2:** `npm run typecheck` — clean. Stop dev server, `rm -rf .next`, `npm run build` — clean.
- [ ] **Step 3:** Restart dev on :3001; dev smoke: `/admin/logistics` → Email Templates tab renders the 8 logistics types; toggle one off/on (persists); click Preview on `VISA_STATUS_UPDATE` → dialog shows the rendered branded email; click Test → success toast (email swallowed by localhost SMTP in dev — fine). Screenshot.
- [ ] **Step 4:** Summarize.
## Notes
- No new email is sent automatically by this wave — it only adds admin visibility/control over the Wave-2 emails.
- Deferred to Wave 4: team-facing "My Logistics" + travel/visa UX fixes.

View File

@@ -0,0 +1,101 @@
# Wave 4 — Team-facing "My Logistics" + travel/visa UX fixes
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
**Goal:** Close the loop for finalist teams. Today a confirmed team can see only their attendee roster + a visa badge; they have no view of their flights or hotel, can't self-enter passport nationality, and the submitter (non-TeamMember lead) can't even see the card. This wave adds a team-facing "My Logistics" view (flights + hotel + visa + nationality self-entry), fixes the submitter gap, links the confirm-success page to the dashboard, and adds the admin-side travel/visa correctness fixes (departure-after-arrival validation, CSV export).
**Architecture:** New read procedures on the applicant router (`getMyLogistics`) reusing the same caller→project resolution as the existing finalist procedures, plus a self-service `updateMyVisaNationality`. A new applicant dashboard card. Admin-side: input validation on `logistics.upsertFlightDetail` + CSV export buttons mirroring the lunch manifest.
**Tech Stack:** Next.js 15, tRPC 11, Prisma 6, shadcn/ui. No schema change.
**Key facts (verified 2026-06-04):**
- `applicant.getMyFinalistConfirmation` (`src/server/routers/applicant.ts:2748`) resolves the project via `teamMembers: { some: { userId } }` — MISSES a lead who submitted but has no TeamMember row, and has no role guard.
- `applicant.getMyVisaApplications` (`:2819`) returns visa rows when `program.visaStatusVisibleToMembers`, else null. Reuse its visibility logic.
- Admin-only: `logistics.getHotel`, `logistics.listFlightDetails` (`src/server/routers/logistics.ts`). No team-facing equivalents → teams never see hotel/flights.
- Applicant dashboard composes cards in `src/app/(applicant)/applicant/page.tsx` (~line 409): `LunchBanner`, `ExternalAttendeesStrip`, `AttendingMembersCard`. Add the new card here.
- Confirm-success copy at `src/app/(public)/finalist/confirm/[token]/page.tsx:183` promises "your project page" with no link.
- `logistics.upsertFlightDetail` input (`:145`) has no departure-after-arrival check. Lunch CSV export pattern: `src/components/admin/logistics/lunch-manifest.tsx:28-57`.
- Models: `Hotel{ name, address?, link?, notes? }` (programId 1:1); `FlightDetail{ arrival/departure At/FlightNumber/Airport, status, adminNotes }` (per AttendingMember); `VisaApplication{ status, nationality?, ... }`.
---
## Task 1: Team-facing read endpoint + submitter fix
**Files:** `src/server/routers/applicant.ts`; test `tests/unit/applicant-my-logistics.test.ts`.
- [ ] **Step 1:** Fix `getMyFinalistConfirmation` resolution: change the `where` to
`{ OR: [{ submittedByUserId: ctx.user.id }, { teamMembers: { some: { userId: ctx.user.id } } }], finalistConfirmation: { isNot: null } }`
(verify `Project.submittedByUserId` is the correct field name in schema). Keep the rest. (No role guard added — APPLICANT/lead may not have an explicit role; the project-scoping is the guard. If other procedures in this router use a role check, match it; otherwise leave project-scoping as the guard and note it.)
- [ ] **Step 2:** Add `getMyLogistics: protectedProcedure.query`:
- Resolve the caller's confirmed-finalist project (same OR-resolution as above; return `null` if none or confirmation not CONFIRMED).
- Return:
```ts
{
projectTitle: string,
confirmationStatus: FinalistConfirmationStatus,
hotel: { name, address, link, notes } | null, // program Hotel (1:1)
myFlight: { arrivalAt, arrivalFlightNumber, arrivalAirport, departureAt, departureFlightNumber, departureAirport, status } | null, // the caller's own AttendingMember.flightDetail
visaVisible: boolean, // program.visaStatusVisibleToMembers
myVisa: { status, nationality } | null, // caller's own VisaApplication, only when visaVisible
}
```
- `myFlight`: find the caller's `AttendingMember` for this confirmation (`where confirmationId + userId`), include `flightDetail`. `hotel`: `prisma.hotel.findUnique({ where: { programId } })`. `myVisa`: only when `visaVisible` and the caller's AttendingMember has a `visaApplication`.
- [ ] **Step 3: Test** — set up a CONFIRMED finalist with the caller as an AttendingMember, a Hotel, a FlightDetail (CONFIRMED), `visaStatusVisibleToMembers:true`, a VisaApplication GRANTED. Call `getMyLogistics` as that user → assert `hotel.name`, `myFlight.arrivalFlightNumber`, `visaVisible:true`, `myVisa.status==='GRANTED'`. Also: a non-finalist user → `null`. (Caller via `createCaller(applicantRouter, { id, email, role:'APPLICANT' })`.)
- [ ] **Step 4:** `npx vitest run tests/unit/applicant-my-logistics.test.ts` → pass; re-run any applicant tests touching these procedures. `npm run typecheck` → clean.
- [ ] **Step 5: Commit** — `git commit -am "feat(applicant): getMyLogistics (hotel+flight+visa) + submitter-match fix"`
---
## Task 2: Self-service nationality entry
**Files:** `src/server/routers/applicant.ts`; test in the same file.
- [ ] **Step 1:** Add `updateMyVisaNationality: protectedProcedure.input(z.object({ nationality: z.string().max(100) })).mutation`:
- Find the caller's `AttendingMember` whose program has `visaStatusVisibleToMembers:true` and which has a `VisaApplication`; if none → `TRPCError NOT_FOUND` ("No visa application to update").
- Update that `VisaApplication.nationality`. Audit `VISA_NATIONALITY_SELF_SET`. Return `{ ok: true }`.
- (Optional nicety: also copy to `User.nationality` if empty.)
- [ ] **Step 2: Test** — caller with a visible VisaApplication sets nationality → assert it persisted. Caller without one → rejects.
- [ ] **Step 3:** Run → pass. `npm run typecheck` → clean.
- [ ] **Step 4: Commit** — `git commit -am "feat(applicant): self-service visa nationality entry"`
---
## Task 3: "My Logistics" card + confirm-page link
**Files:** create `src/components/applicant/my-logistics-card.tsx`; modify `src/app/(applicant)/applicant/page.tsx`, `src/app/(public)/finalist/confirm/[token]/page.tsx`.
- [ ] **Step 1:** Build `MyLogisticsCard` (`'use client'`): `trpc.applicant.getMyLogistics.useQuery()`. If `null` or loading → render nothing / Skeleton. Otherwise a Card "Your grand-finale logistics" with sections:
- **Hotel** — name + address + link (if present), else "Hotel details coming soon."
- **Flights** — the caller's arrival/departure (flight no, airport, formatted date/time in Europe/Paris) + a status badge, else "Your flight details will appear here once arranged."
- **Visa** (only if `visaVisible`) — status badge + a nationality field: shows current `myVisa.nationality` or an inline editable input → `trpc.applicant.updateMyVisaNationality` (sonner toast + invalidate).
Follow the visual pattern of `attending-members-card.tsx`. Visible affordances only.
- [ ] **Step 2:** Render `<MyLogisticsCard />` in `src/app/(applicant)/applicant/page.tsx` near `AttendingMembersCard` (~line 415).
- [ ] **Step 3:** In the confirm-success block (`finalist/confirm/[token]/page.tsx:168-185`), replace the dead "your project page" sentence with a real link/button to `/applicant` ("Go to my dashboard"). (The confirm page is public; the link will hit auth and land them on their dashboard after login.)
- [ ] **Step 4:** `npm run typecheck` → clean.
- [ ] **Step 5: Commit** — `git commit -am "feat(applicant): My Logistics card (hotel/flights/visa+nationality) + confirm-page dashboard link"`
---
## Task 4: Admin travel/visa UX — validation + CSV export
**Files:** `src/server/routers/logistics.ts`, `src/components/admin/logistics/travel-tab.tsx`, `src/components/admin/logistics/visas-tab.tsx`; test `tests/unit/logistics-flight.test.ts` (extend).
- [ ] **Step 1:** Server validation in `logistics.upsertFlightDetail`: after building `data`, if both `arrivalAt` and `departureAt` are present and `departureAt < arrivalAt`, throw `TRPCError BAD_REQUEST` ("Departure must be after arrival"). Add a test asserting the rejection.
- [ ] **Step 2:** CSV export — add a "Download CSV" button to `travel-tab.tsx` (columns: Project, Attendee, Email, Arrival date/time, Arrival flight, Arrival airport, Departure date/time, Departure flight, Departure airport, Status, Needs visa) and to `visas-tab.tsx` (columns: Project, Attendee, Email, Nationality, Status, Invitation sent, Appointment, Decision, Notes). MIRROR the CSV builder in `src/components/admin/logistics/lunch-manifest.tsx:28-57` (Blob + anchor download; escape commas/quotes). Build from the data already loaded by each tab's query.
- [ ] **Step 3:** `npx vitest run tests/unit/logistics-flight.test.ts` → pass. `npm run typecheck` → clean.
- [ ] **Step 4: Commit** — `git commit -am "feat(logistics): departure-after-arrival validation + travel/visa CSV export"`
---
## Task 5: Verify
- [ ] **Step 1:** `npx vitest run` — full suite green.
- [ ] **Step 2:** `npm run typecheck` — clean. Stop dev, `rm -rf .next`, `npm run build` — clean.
- [ ] **Step 3:** Restart dev on :3001. Dev smoke: as admin, enroll a team (ADMIN_CONFIRM) and add flight + hotel + set a visa; then log in as that team's lead (seed user `matt@letsbe.solutions` is a MEMBER of "Revamp Flips" — or use the lead) and confirm the "My Logistics" card shows the hotel + flight + visa + nationality field. Verify the CSV export downloads. Clean up.
- [ ] **Step 4:** Summarize.
## Notes
- Deep timezone overhaul of the admin flight `datetime-local` inputs (storing explicit Europe/Paris) is a separate design decision — NOT in this wave; the validation + Europe/Paris display labels are the pragmatic improvement. Flag as remaining polish.
- This completes the 4-wave logistics overhaul.

View File

@@ -0,0 +1,745 @@
# Grand Final Judge-Doc Curation + Optional Uploads Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let admins curate which previously-submitted documents finale judges see, and make the "optional revised uploads" mode render correctly for finalists.
**Architecture:** Everything builds on the existing `final-documents.ts` service + `finalist` tRPC router + the LIVE_FINAL round's `configJson` (same pattern as the shipped `allowFinalistRevisedUploads` toggle). A new configJson key `reviewVisibleRequirementIds` filters the judge review payload; new status fields `hasRequired`/`allUploaded` drive optional-mode rendering in the finalist banner/panel. No schema migration.
**Tech Stack:** Next.js 15 App Router, tRPC 11 + Zod, Prisma 6, Vitest 4 (sequential, real test DB), shadcn/ui (Card/Switch/Checkbox).
**Spec:** `docs/superpowers/specs/2026-06-09-finale-doc-curation-optional-uploads-design.md`
**Conventions for every task:** TypeScript strict, `type` over `interface`. Tests use the factories in `tests/helpers.ts` (`createTestProgram`, `createTestCompetition`, `createTestRound`, `createTestProject`, `createTestProjectRoundState`, `createTestUser`, `uid`) and clean up with `cleanupTestData(programId, userIds?)` in `afterAll`. Run a single file with `npx vitest run tests/unit/<file>.test.ts`.
---
### Task 1: `hasRequired` + `allUploaded` on `FinalDocumentStatus`
**Files:**
- Modify: `src/server/services/final-documents.ts` (type at ~line 14, computation at ~line 95)
- Test: `tests/unit/final-documents.test.ts` (extend existing file)
- [ ] **Step 1: Write the failing tests**
In `tests/unit/final-documents.test.ts`, first extend the `makeFinaleProgram` factory (top of file) with an `optionalRequirements` option — change the two `fileRequirement.create` calls to use it:
```ts
async function makeFinaleProgram(
opts: { roundStatus?: 'ROUND_ACTIVE' | 'ROUND_DRAFT' | 'ROUND_CLOSED'; closeAt?: Date; skipRequirements?: boolean; uploadsEnabled?: boolean; optionalRequirements?: boolean } = {},
) {
// ... existing body unchanged, except both requirement creates:
// isRequired: !opts.optionalRequirements
}
```
Then add inside the existing `describe('getFinalDocumentStatusForProject', ...)` block:
```ts
it('all-optional round: hasRequired false, allUploaded flips when every slot has a file', async () => {
const { program, round, reqPlan, reqVideo } = await makeFinaleProgram({ optionalRequirements: true })
const project = await createTestProject(program.id)
await createTestProjectRoundState(project.id, round.id)
const before = await getFinalDocumentStatusForProject(prisma, project.id)
expect(before!.hasRequired).toBe(false)
expect(before!.allUploaded).toBe(false)
expect(before!.allRequiredUploaded).toBe(false)
for (const req of [reqPlan!, reqVideo!]) {
await prisma.projectFile.create({
data: {
id: uid('file'), projectId: project.id, roundId: round.id, requirementId: req.id,
fileType: 'SUPPORTING_DOC', fileName: `f-${req.id}`, mimeType: 'application/pdf', size: 10,
bucket: 'b', objectKey: uid('key'),
},
})
}
const after = await getFinalDocumentStatusForProject(prisma, project.id)
expect(after!.hasRequired).toBe(false)
expect(after!.allUploaded).toBe(true)
})
it('mixed round: hasRequired true; allUploaded only when optional slots are filled too', async () => {
const { program, round, reqPlan } = await makeFinaleProgram()
await prisma.fileRequirement.update({ where: { id: reqPlan!.id }, data: { isRequired: false } })
const project = await createTestProject(program.id)
await createTestProjectRoundState(project.id, round.id)
const status = await getFinalDocumentStatusForProject(prisma, project.id)
expect(status!.hasRequired).toBe(true) // reqVideo still required
expect(status!.allUploaded).toBe(false)
})
it('zero slots: allUploaded false (no vacuous completeness)', 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!.hasRequired).toBe(false)
expect(status!.allUploaded).toBe(false)
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run tests/unit/final-documents.test.ts`
Expected: the 3 new tests FAIL with TypeScript/undefined errors on `hasRequired` / `allUploaded` (fields don't exist yet); all pre-existing tests still pass.
- [ ] **Step 3: Implement**
In `src/server/services/final-documents.ts`, extend the type (~line 14):
```ts
export type FinalDocumentStatus = {
roundId: string
roundName: string
deadline: Date | null
deadlinePassed: boolean
requirements: FinalDocRequirement[]
allRequiredUploaded: boolean
hasRequired: boolean // any slot is marked required
allUploaded: boolean // every listed slot has a file (false when no slots exist)
}
```
And in `getFinalDocumentStatusForProject` (~line 95), replace the return-value computation:
```ts
const required = reqStatuses.filter((r) => r.isRequired)
const allRequiredUploaded = required.length > 0 && required.every((r) => r.uploaded)
const hasRequired = required.length > 0
const allUploaded = reqStatuses.length > 0 && reqStatuses.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,
hasRequired,
allUploaded,
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx vitest run tests/unit/final-documents.test.ts`
Expected: ALL tests pass (new + pre-existing).
- [ ] **Step 5: Commit**
```bash
git add src/server/services/final-documents.ts tests/unit/final-documents.test.ts
git commit -m "feat(final-docs): hasRequired/allUploaded on FinalDocumentStatus for optional-uploads mode"
```
---
### Task 2: Curation filter in `listFinalistDocumentsForReview`
**Files:**
- Modify: `src/server/services/final-documents.ts` (`listFinalistDocumentsForReview`, ~line 257; new helper next to `finalistUploadsEnabled` ~line 45)
- Test: Create `tests/unit/final-documents-curation.test.ts`
- [ ] **Step 1: Write the failing tests**
Create `tests/unit/final-documents-curation.test.ts`. The review service presigns every file via MinIO, so mock `getPresignedUrl` (partial module mock — keeps `BUCKET_NAME` etc. real):
```ts
import { describe, it, expect, afterAll, vi } from 'vitest'
vi.mock('@/lib/minio', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/minio')>()
return { ...actual, getPresignedUrl: vi.fn(async () => 'https://example.test/presigned') }
})
import { prisma } from '../setup'
import {
createTestProgram,
createTestCompetition,
createTestRound,
createTestProject,
createTestProjectRoundState,
cleanupTestData,
uid,
} from '../helpers'
import { listFinalistDocumentsForReview } from '@/server/services/final-documents'
const programIds: string[] = []
afterAll(async () => {
for (const id of programIds) await cleanupTestData(id)
})
/**
* One finalist team with 4 files:
* - Business Plan (prior SUBMISSION round, via requirement reqBP)
* - Pitch Deck (prior SUBMISSION round, via requirement reqDeck)
* - loose.pdf (prior SUBMISSION round, NO requirement)
* - final.mp4 (uploaded directly to the LIVE_FINAL round, via reqFinal)
*/
async function setupCuration() {
const program = await createTestProgram()
programIds.push(program.id)
const comp = await createTestCompetition(program.id, { status: 'ACTIVE' })
const priorRound = await createTestRound(comp.id, { roundType: 'SUBMISSION', status: 'ROUND_CLOSED', sortOrder: 2 })
const reqBP = await prisma.fileRequirement.create({
data: { id: uid('req'), roundId: priorRound.id, name: 'Business Plan', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 },
})
const reqDeck = await prisma.fileRequirement.create({
data: { id: uid('req'), roundId: priorRound.id, name: 'Pitch Deck', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 2 },
})
const finale = await createTestRound(comp.id, {
roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6,
windowCloseAt: new Date(Date.now() + 86_400_000),
configJson: { allowFinalistRevisedUploads: true },
})
const reqFinal = await prisma.fileRequirement.create({
data: { id: uid('req'), roundId: finale.id, name: '1-minute Video', acceptedMimeTypes: ['video/*'], isRequired: false, sortOrder: 1 },
})
const project = await createTestProject(program.id)
await createTestProjectRoundState(project.id, finale.id)
const mkFile = (roundId: string, requirementId: string | null, fileName: string) =>
prisma.projectFile.create({
data: {
id: uid('file'), projectId: project.id, roundId, requirementId,
fileType: 'SUPPORTING_DOC', fileName, mimeType: 'application/pdf', size: 10,
bucket: 'b', objectKey: uid('key'),
},
})
await mkFile(priorRound.id, reqBP.id, 'bp.pdf')
await mkFile(priorRound.id, reqDeck.id, 'deck.pdf')
await mkFile(priorRound.id, null, 'loose.pdf')
await mkFile(finale.id, reqFinal.id, 'final.mp4')
return { program, priorRound, finale, reqBP, reqDeck, reqFinal, project }
}
async function setSelection(roundId: string, ids: string[] | null) {
const round = await prisma.round.findUniqueOrThrow({ where: { id: roundId }, select: { configJson: true } })
const cfg = (round.configJson ?? {}) as Record<string, unknown>
if (ids === null) delete cfg.reviewVisibleRequirementIds
else cfg.reviewVisibleRequirementIds = ids
await prisma.round.update({ where: { id: roundId }, data: { configJson: cfg as object } })
}
describe('listFinalistDocumentsForReview curation', () => {
it('no selection key → all files visible (current behavior)', async () => {
const { program } = await setupCuration()
const result = await listFinalistDocumentsForReview(prisma, program.id)
expect(result.teams).toHaveLength(1)
expect(result.teams[0].files).toHaveLength(4)
})
it('selection → only matching prior files, finale uploads always visible', async () => {
const { program, finale, reqBP } = await setupCuration()
await setSelection(finale.id, [reqBP.id])
const result = await listFinalistDocumentsForReview(prisma, program.id)
const names = result.teams[0].files.map((f) => f.fileName).sort()
expect(names).toEqual(['bp.pdf', 'final.mp4']) // deck.pdf and loose.pdf hidden
})
it('empty selection → only finale uploads visible', async () => {
const { program, finale } = await setupCuration()
await setSelection(finale.id, [])
const result = await listFinalistDocumentsForReview(prisma, program.id)
expect(result.teams[0].files.map((f) => f.fileName)).toEqual(['final.mp4'])
})
it('prior file without a requirement is excluded under any selection', async () => {
const { program, finale, reqBP, reqDeck } = await setupCuration()
await setSelection(finale.id, [reqBP.id, reqDeck.id])
const result = await listFinalistDocumentsForReview(prisma, program.id)
expect(result.teams[0].files.map((f) => f.fileName)).not.toContain('loose.pdf')
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
Expected: the first test PASSES (current behavior), the other three FAIL (filter not implemented — they see all 4 files).
- [ ] **Step 3: Implement**
In `src/server/services/final-documents.ts`, add a config reader next to `finalistUploadsEnabled` (~line 45):
```ts
/**
* Which prior-round FileRequirement ids are visible to finale judges.
* null = no curation (show all prior files). Empty array = hide all prior
* files (Grand Final round uploads are always shown regardless).
*/
export function reviewVisibleRequirementIds(configJson: unknown): string[] | null {
const v = (configJson as { reviewVisibleRequirementIds?: unknown } | null)?.reviewVisibleRequirementIds
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : null
}
```
In `listFinalistDocumentsForReview`:
1. After the `if (!round) return ...` guard, read the selection: `const visibleIds = reviewVisibleRequirementIds(round.configJson)`
2. Add `requirementId: true` to the `allFiles` select.
3. At the top of the `for (const f of allFiles)` loop, before building `rf`:
```ts
const isFinaleUpload = f.roundId === round.id
// Curated mode: prior-round files must match a selected requirement; finale uploads always pass.
if (!isFinaleUpload && visibleIds !== null && (!f.requirementId || !visibleIds.includes(f.requirementId))) continue
```
4. Use the `isFinaleUpload` const in the `rf` object (replacing the inline `f.roundId === round.id`).
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
Expected: all 4 PASS.
Also run the neighbors to catch regressions: `npx vitest run tests/unit/final-documents.test.ts`
Expected: all PASS.
- [ ] **Step 5: Commit**
```bash
git add src/server/services/final-documents.ts tests/unit/final-documents-curation.test.ts
git commit -m "feat(final-docs): filter judge review by reviewVisibleRequirementIds (finale uploads always shown)"
```
---
### Task 3: Picker options helper `listReviewVisibilityOptions`
**Files:**
- Modify: `src/server/services/final-documents.ts` (new exported function + type, after `listFinalistDocumentsForReview`)
- Test: `tests/unit/final-documents-curation.test.ts` (extend)
- [ ] **Step 1: Write the failing tests**
Add to `tests/unit/final-documents-curation.test.ts` (import `listReviewVisibilityOptions` from the same service module):
```ts
describe('listReviewVisibilityOptions', () => {
it('lists distinct prior-round slots with counts; excludes finale-round slots and requirement-less files', async () => {
const { program, reqBP, reqDeck } = await setupCuration()
const options = await listReviewVisibilityOptions(prisma, program.id)
expect(options.map((o) => o.requirementId).sort()).toEqual([reqBP.id, reqDeck.id].sort())
const bp = options.find((o) => o.requirementId === reqBP.id)!
expect(bp.name).toBe('Business Plan')
expect(bp.fileCount).toBe(1)
expect(bp.roundName).toBeTruthy()
})
it('returns [] when there is no open finale round', async () => {
const program = await createTestProgram()
programIds.push(program.id)
expect(await listReviewVisibilityOptions(prisma, program.id)).toEqual([])
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
Expected: FAIL — `listReviewVisibilityOptions` is not exported.
- [ ] **Step 3: Implement**
In `src/server/services/final-documents.ts`, after `listFinalistDocumentsForReview`:
```ts
export type ReviewDocSlot = {
requirementId: string
name: string
roundName: string
roundSort: number
fileCount: number
}
/**
* Distinct prior-round document slots (FileRequirements) that the finalist
* teams have files for — the options offered in the admin "documents shown to
* judges" picker. Excludes the finale round's own slots (those uploads are
* always visible to judges) and files without a requirement.
*/
export async function listReviewVisibilityOptions(prisma: PrismaClient, programId: string): Promise<ReviewDocSlot[]> {
const round = await getOpenFinaleRound(prisma, programId)
if (!round) return []
const states = await prisma.projectRoundState.findMany({ where: { roundId: round.id }, select: { projectId: true } })
const files = await prisma.projectFile.findMany({
where: { projectId: { in: states.map((s) => s.projectId) }, requirement: { roundId: { not: round.id } } },
select: {
requirementId: true,
requirement: { select: { name: true, round: { select: { name: true, sortOrder: true } } } },
},
})
const slots = new Map<string, ReviewDocSlot>()
for (const f of files) {
if (!f.requirementId || !f.requirement) continue
const existing = slots.get(f.requirementId)
if (existing) existing.fileCount++
else slots.set(f.requirementId, {
requirementId: f.requirementId,
name: f.requirement.name.trim(),
roundName: f.requirement.round.name,
roundSort: f.requirement.round.sortOrder,
fileCount: 1,
})
}
return [...slots.values()].sort((a, b) => a.roundSort - b.roundSort || a.name.localeCompare(b.name))
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
Expected: all PASS.
- [ ] **Step 5: Commit**
```bash
git add src/server/services/final-documents.ts tests/unit/final-documents-curation.test.ts
git commit -m "feat(final-docs): listReviewVisibilityOptions — distinct prior-round doc slots for the curation picker"
```
---
### Task 4: tRPC procedures `getReviewDocSettings` / `setReviewVisibleRequirements`
**Files:**
- Modify: `src/server/routers/finalist.ts` (add two procedures next to `getRevisedUploadSetting`/`setRevisedUploadSetting`, ~line 1695; extend the existing `@/server/services/final-documents` import with `listReviewVisibilityOptions` and `reviewVisibleRequirementIds`)
- Test: `tests/unit/final-documents-curation.test.ts` (extend)
- [ ] **Step 1: Write the failing tests**
Add to `tests/unit/final-documents-curation.test.ts`:
```ts
import * as finalistRouter from '@/server/routers/finalist'
import { createCaller } from '../setup'
import { createTestUser } from '../helpers' // merge into the existing helpers import
describe('finalist review-doc settings procedures', () => {
const userIds: string[] = []
afterAll(async () => {
// cleanupTestData of programIds already runs in the file-level afterAll;
// pass userIds through an extra cleanup for the admin users:
for (const id of programIds) await cleanupTestData(id, userIds)
})
it('round-trips a selection and preserves sibling configJson keys', async () => {
const { program, finale, reqBP } = await setupCuration()
const admin = await createTestUser('PROGRAM_ADMIN')
userIds.push(admin.id)
const caller = createCaller(finalistRouter.finalistRouter, admin)
const initial = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id })
expect(initial.selectedIds).toBeNull()
expect(initial.options.length).toBe(2)
await caller.setReviewVisibleRequirements({ roundId: finale.id, requirementIds: [reqBP.id] })
const curated = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id })
expect(curated.selectedIds).toEqual([reqBP.id])
// sibling key from setupCuration must survive
const round = await prisma.round.findUniqueOrThrow({ where: { id: finale.id }, select: { configJson: true } })
expect((round.configJson as Record<string, unknown>).allowFinalistRevisedUploads).toBe(true)
await caller.setReviewVisibleRequirements({ roundId: finale.id, requirementIds: null })
const cleared = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id })
expect(cleared.selectedIds).toBeNull()
})
it('rejects a non-LIVE_FINAL round', async () => {
const { program, priorRound } = await setupCuration()
const admin = await createTestUser('PROGRAM_ADMIN')
userIds.push(admin.id)
const caller = createCaller(finalistRouter.finalistRouter, admin)
await expect(
caller.setReviewVisibleRequirements({ roundId: priorRound.id, requirementIds: [] }),
).rejects.toThrow()
void program
})
})
```
(Adjust the top-of-file `../helpers` import to include `createTestUser` rather than re-importing.)
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
Expected: FAIL — `getReviewDocSettings` does not exist on the router.
- [ ] **Step 3: Implement**
In `src/server/routers/finalist.ts`, extend the existing service import with `listReviewVisibilityOptions, reviewVisibleRequirementIds`, then add after `setRevisedUploadSetting`:
```ts
/** Options + current selection for the "documents shown to judges" picker. */
getReviewDocSettings: adminProcedure
.input(z.object({ programId: z.string(), roundId: z.string() }))
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true } })
return {
options: await listReviewVisibilityOptions(ctx.prisma, input.programId),
selectedIds: reviewVisibleRequirementIds(round?.configJson ?? null),
}
}),
/** Set which prior-round documents finale judges see. null = show all (clears curation). */
setReviewVisibleRequirements: adminProcedure
.input(z.object({ roundId: z.string(), requirementIds: z.array(z.string()).nullable() }))
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true, roundType: true } })
if (!round || round.roundType !== 'LIVE_FINAL') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not a grand-final round' })
}
const { reviewVisibleRequirementIds: _omit, ...rest } = (round.configJson ?? {}) as Record<string, unknown>
const next = input.requirementIds === null ? rest : { ...rest, reviewVisibleRequirementIds: input.requirementIds }
await ctx.prisma.round.update({ where: { id: input.roundId }, data: { configJson: next } })
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'FINALIST_REVIEW_DOCS_CURATED',
entityType: 'Round',
entityId: input.roundId,
detailsJson: { requirementIds: input.requirementIds },
})
return { ok: true }
}),
```
Note: `data: { configJson: next }` may need `next as Prisma.InputJsonValue` depending on inference — match how the file already imports/uses Prisma types if the typechecker complains.
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
Expected: all PASS. Then `npm run typecheck` — clean.
- [ ] **Step 5: Commit**
```bash
git add src/server/routers/finalist.ts tests/unit/final-documents-curation.test.ts
git commit -m "feat(final-docs): admin procedures to read/set judge-visible document curation"
```
---
### Task 5: Optional-mode rendering — banner + panel
No component-test infrastructure exists in this repo (vitest covers server code only) — verify via typecheck + the manual smoke in Task 7.
**Files:**
- Modify: `src/components/applicant/final-documents-banner.tsx`
- Modify: `src/components/applicant/final-documents-panel.tsx`
- [ ] **Step 1: Update the banner**
In `final-documents-banner.tsx`:
1. Guard (line 11): `if (!status || status.requirements.length === 0) return null`
2. Replace the `done` computation (line 18) and add the mode flag:
```ts
const optionalMode = !status.hasRequired
const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded
```
3. Replace the title span (lines 26-28):
```tsx
<span className="font-semibold">
{done
? optionalMode ? 'Grand Final documents uploaded' : 'Grand Final documents submitted'
: optionalMode ? 'Upload updated Grand Final documents (optional)' : 'Upload your Grand Final documents'}
</span>
```
Everything else (styling, checklist, deadline, button gated on `!done`) stays as is.
- [ ] **Step 2: Update the panel**
In `final-documents-panel.tsx`:
1. Guard (line 21): `if (!status || status.requirements.length === 0) return null`
2. Add after the guard: `const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded`
3. Replace both `status.allRequiredUploaded` usages (badge at line 29, team upload-button gate at line 51) with `done`.
4. Badge label: `{status.hasRequired ? 'Submitted' : 'Uploaded'}`
5. Description (line 38) — append the optional hint:
```tsx
<CardDescription>
{props.variant === 'team' ? 'Your final deliverables for the Grand Finale.' : 'This team\'s final deliverables for the Grand Finale.'}
{!status.hasRequired && ' These uploads are optional.'}
</CardDescription>
```
- [ ] **Step 3: Typecheck**
Run: `npm run typecheck`
Expected: clean. (The tRPC client types pick up `hasRequired`/`allUploaded` from Task 1 automatically.)
- [ ] **Step 4: Commit**
```bash
git add src/components/applicant/final-documents-banner.tsx src/components/applicant/final-documents-panel.tsx
git commit -m "feat(final-docs): optional-mode rendering for finalist banner + panel"
```
---
### Task 6: Admin "Documents shown to judges" card
**Files:**
- Create: `src/components/admin/grand-finale/review-docs-picker.tsx`
- Modify: `src/app/(admin)/admin/rounds/[roundId]/page.tsx` (import near line 100; render near line 1535)
- [ ] **Step 1: Create the picker component**
`src/components/admin/grand-finale/review-docs-picker.tsx` (full file):
```tsx
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { toast } from 'sonner'
import { Eye } from 'lucide-react'
/**
* Admin picker: which previously-submitted documents finale judges see on the
* review page. Default (switch off) shows everything; switching to curated
* mode starts with all slots ticked, and the admin unticks what to hide.
* Grand Final round uploads are always visible regardless.
*/
export function ReviewDocsPicker({ programId, roundId }: { programId: string; roundId: string }) {
const utils = trpc.useUtils()
const { data } = trpc.finalist.getReviewDocSettings.useQuery({ programId, roundId })
const set = trpc.finalist.setReviewVisibleRequirements.useMutation({
onSuccess: () => utils.finalist.getReviewDocSettings.invalidate({ programId, roundId }),
onError: (e) => toast.error(e.message),
})
if (!data || data.options.length === 0) return null
const curated = data.selectedIds !== null
const selected = new Set(data.selectedIds ?? data.options.map((o) => o.requirementId))
const toggleSlot = (id: string, on: boolean) => {
const next = new Set(selected)
if (on) next.add(id)
else next.delete(id)
set.mutate({ roundId, requirementIds: [...next] })
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Eye className="h-5 w-5" /> Documents shown to judges
</CardTitle>
<CardDescription>
Choose which previously submitted documents judges see on the finalist review page.
Documents uploaded directly to this Grand Final round are always visible.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2">
<Switch
id="curate-review-docs"
checked={curated}
disabled={set.isPending}
onCheckedChange={(v) =>
set.mutate({ roundId, requirementIds: v ? data.options.map((o) => o.requirementId) : null })}
/>
<Label htmlFor="curate-review-docs" className="text-sm text-muted-foreground cursor-pointer">
{curated ? 'Curated — judges see only the checked documents' : 'Showing all submitted documents'}
</Label>
</div>
{curated && (
<div className="space-y-2">
{data.options.map((o) => (
<label key={o.requirementId} className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={selected.has(o.requirementId)}
disabled={set.isPending}
onCheckedChange={(v) => toggleSlot(o.requirementId, v === true)}
/>
<span>{o.name} {o.roundName}</span>
<span className="text-xs text-muted-foreground">
({o.fileCount} file{o.fileCount === 1 ? '' : 's'})
</span>
</label>
))}
</div>
)}
</CardContent>
</Card>
)
}
```
- [ ] **Step 2: Wire into the round admin page**
In `src/app/(admin)/admin/rounds/[roundId]/page.tsx`:
Import (next to the other grand-finale imports, ~line 100):
```tsx
import { ReviewDocsPicker } from '@/components/admin/grand-finale/review-docs-picker'
```
Render inside the existing `isGrandFinale && programId` block, directly after the flex row containing `<FinalDocsUploadsToggle …>` (the `</div>` around line 1545):
```tsx
<ReviewDocsPicker programId={programId} roundId={roundId} />
```
- [ ] **Step 3: Typecheck**
Run: `npm run typecheck`
Expected: clean.
- [ ] **Step 4: Commit**
```bash
git add src/components/admin/grand-finale/review-docs-picker.tsx "src/app/(admin)/admin/rounds/[roundId]/page.tsx"
git commit -m "feat(final-docs): admin card to curate documents shown to finale judges"
```
---
### Task 7: Full verification
**Files:** none (verification only)
- [ ] **Step 1: Full test suite**
Run: `npx vitest run`
Expected: all tests pass (was 321 before this work; now more).
- [ ] **Step 2: Lint + typecheck + build**
Run: `npm run lint && npm run typecheck && npm run build`
Expected: all clean. (CLAUDE.md: always build before push.)
- [ ] **Step 3: Manual smoke (dev server + Playwright or browser)**
1. As admin, open the Grand Final round page → the "Documents shown to judges" card lists the prior-round slots with counts; flip to curated, untick one slot.
2. Open `/admin/finals-documents` → the unticked document type disappears from every team; any Grand Final uploads remain.
3. Flip the curation switch off → all documents reappear.
4. With `allowFinalistRevisedUploads` ON and all finale slots optional (set in dev data), check the applicant dashboard banner shows "Upload updated Grand Final documents (optional)" and turns green only when every slot is filled.
- [ ] **Step 4: Commit anything outstanding**
```bash
git status --short # should be clean except untracked screenshots/docs
```
---
## Self-Review (completed at planning time)
- **Spec coverage:** configJson key + semantics → Tasks 2/4; always-visible finale uploads → Task 2; requirement-less files excluded under selection → Task 2; picker options with counts → Task 3; admin UI next to toggle → Task 6; `hasRequired`/`allUploaded` + zero-slot edge → Tasks 1/5; sibling-key preservation + audit → Task 4; reminders unchanged → verified in spec, no task needed.
- **Placeholder scan:** none.
- **Type consistency:** `ReviewDocSlot`, `reviewVisibleRequirementIds(configJson)`, `getReviewDocSettings`/`setReviewVisibleRequirements`, `hasRequired`/`allUploaded` used consistently across tasks.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,592 @@
# Grand Finale Ceremony System Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship the full Option-C grand-finale ceremony system (admin-driven presentation phases with real timers + overtime log, audience QR favorite-voting with per-category windows, persisted juror notes/comments, deliberation completion, big-screen ceremony view with cinematic results reveal) before the 2026-06-11 event.
**Architecture:** Extend the existing `LiveProgressCursor` with a per-project phase state machine and server-stamped timers; extend `LiveVotingSession` with audience-window state and a new `AudienceFavoriteVote` pick-one model; big screen is a pure derivation of existing state plus a small `RevealState` controller. No new session-level phase machine.
**Tech Stack:** Next.js 15 App Router, tRPC 11, Prisma 6/PostgreSQL, Tailwind 4 + shadcn/ui, `motion` v11 (already installed) for reveal animations, `qrcode.react` (new tiny dep), Vitest 4.
**Spec:** `docs/superpowers/specs/2026-06-10-grand-finale-ceremony-design.md`
**Critical context for the implementer:**
- Two parallel live systems exist: `live.ts` (LiveProgressCursor, cohort-based votes) and `live-voting.ts` (LiveVotingSession, jury criteria votes + audience tokens). The finale uses **cursor for presentation flow** and **LiveVotingSession for all voting**. The cohort-based `castVote`/`castStageVote` in `live.ts` are NOT used for the finale — leave them alone.
- Source of truth for presentation order: `round.configJson.projectOrder` (managed by `live.start`/`live.reorder`).
- Known bug: jury live page passes `params.roundId` as `sessionId` to `getSessionForVoting` → NOT_FOUND. Fixed in Task 7.
- Known bug: deliberation jury page has `juryMemberId: ''` and `hasVoted = false` hardcoded. Fixed in Task 10.
- `LiveVotingSession.roundId` is `@unique`, so by-round lookup is safe.
- Project category field is `competitionCategory` (`STARTUP | BUSINESS_CONCEPT`), nullable.
- **NEVER** run `prisma migrate dev` if `migrate status` shows drift (memory rule) — use the create-only + `db execute` + `migrate resolve` path in Task 2.
- Run tests with `npx vitest run tests/unit/<file>` (sequential forks pool). Build check: `npm run build`. Always build before push.
---
### Task 1: Public paths for audience + ceremony routes
**Files:**
- Modify: `src/lib/auth.config.ts:52-65`
- Test: `tests/unit/auth-public-paths.test.ts` (extend existing)
- [ ] **Step 1: Extend the existing public-paths test** — read `tests/unit/auth-public-paths.test.ts` first and follow its existing assertion style; add cases asserting `/vote/competition/abc`, `/vote/xyz`, `/live-scores/xyz`, `/live/ceremony/abc` are public and that `/live` alone (jury route prefix is `/jury/...` so no conflict) does not accidentally open admin routes (assert `/admin` still private).
- [ ] **Step 2: Run** `npx vitest run tests/unit/auth-public-paths.test.ts` — expect new cases FAIL.
- [ ] **Step 3: Implement** — in `src/lib/auth.config.ts` add to `publicPaths`:
```ts
'/vote', // audience QR voting (token-based, no account)
'/live-scores', // public live scoreboard
'/live/ceremony', // big-screen ceremony view (projector)
```
- [ ] **Step 4: Run test again** — expect PASS. Also `curl -sI http://localhost:3000/vote/competition/x | head -3` (dev server) → must NOT be a redirect to /login.
- [ ] **Step 5: Commit** `fix(auth): make audience vote, live-scores and ceremony routes public`
---
### Task 2: Schema migration
**Files:**
- Modify: `prisma/schema.prisma` (LiveProgressCursor ~2152, LiveVotingSession ~1165, LiveVote ~1202, AudienceVoter ~1230, Round, Project, User back-relations)
- Create: `prisma/migrations/<ts>_grand_finale_ceremony/migration.sql`
- [ ] **Step 1: Add enums** (near other enums):
```prisma
enum LivePhase {
ON_DECK
PRESENTING
QA
SCORING
}
enum AudiencePhase {
CLOSED
OPEN
}
```
- [ ] **Step 2: Extend `LiveProgressCursor`:**
```prisma
projectPhase LivePhase @default(ON_DECK)
phaseStartedAt DateTime?
phaseDurationSeconds Int?
phasePausedAt DateTime?
phasePausedAccumMs Int @default(0)
timingLogJson Json? @db.JsonB // [{projectId, phase, startedAt, endedAt, configuredSeconds, overranSeconds}]
overrideSlide String? // 'welcome' | 'break' | 'deliberation' | 'thanks'
```
- [ ] **Step 3: Extend `LiveVotingSession`:**
```prisma
// Audience favorite-vote window (grand finale)
audiencePhase AudiencePhase @default(CLOSED)
audienceWindowKey String? // 'CATEGORY:STARTUP' | 'CATEGORY:BUSINESS_CONCEPT' | 'OVERALL'
audienceWindowOpenedAt DateTime?
audienceWindowClosesAt DateTime?
allowOverallFavorite Boolean @default(false)
```
and relations `favoriteVotes AudienceFavoriteVote[]`, `revealState RevealState?`.
- [ ] **Step 4: Extend `LiveVote`** with `comment String? @db.Text`, and `AudienceVoter` with `favoriteVotes AudienceFavoriteVote[]`.
- [ ] **Step 5: New models** (after AudienceVoter):
```prisma
model AudienceFavoriteVote {
id String @id @default(cuid())
sessionId String
windowKey String // matches LiveVotingSession.audienceWindowKey at cast time
projectId String
audienceVoterId String
ipAddress String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
audienceVoter AudienceVoter @relation(fields: [audienceVoterId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@unique([sessionId, windowKey, audienceVoterId])
@@index([sessionId, windowKey, ipAddress])
@@index([sessionId, windowKey, projectId])
}
model LiveNote {
id String @id @default(cuid())
roundId String
projectId String
userId String
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([roundId, projectId, userId])
@@index([userId])
}
model RevealState {
id String @id @default(cuid())
sessionId String @unique
status String @default("DRAFT") // DRAFT | ARMED | REVEALING | DONE
stepsJson Json @db.JsonB // RevealStep[] — see Task 8
currentStepIndex Int @default(-1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
}
```
Add back-relations: `Project.audienceFavoriteVotes AudienceFavoriteVote[]`, `Project.liveNotes LiveNote[]`, `Round.liveNotes LiveNote[]`, `User.liveNotes LiveNote[]`.
- [ ] **Step 6: Migrate safely.** `npx prisma migrate status` first. If clean: `npx prisma migrate dev --name grand_finale_ceremony`. If drifted: `npx prisma migrate dev --create-only --name grand_finale_ceremony`, review SQL, then `npx prisma db execute --file prisma/migrations/<ts>_grand_finale_ceremony/migration.sql` and `npx prisma migrate resolve --applied <ts>_grand_finale_ceremony`. Then `npx prisma generate`.
- [ ] **Step 7: Verify** `npm run typecheck` passes (pre-existing errors aside). Commit `feat(finale): schema for phases, audience windows, favorite votes, notes, reveal`.
---
### Task 3: Timer helper `src/lib/live-timer.ts`
**Files:**
- Create: `src/lib/live-timer.ts`
- Test: `tests/unit/live-timer.test.ts`
- [ ] **Step 1: Write failing tests** (pure functions, no DB):
```ts
import { describe, it, expect } from 'vitest'
import { elapsedMs, remainingSeconds, formatClock } from '@/lib/live-timer'
const t0 = new Date('2026-06-11T10:00:00Z')
const at = (s: number) => new Date(t0.getTime() + s * 1000)
describe('live-timer', () => {
it('elapsedMs counts from phaseStartedAt', () => {
expect(elapsedMs({ phaseStartedAt: t0, phaseDurationSeconds: 300, phasePausedAt: null, phasePausedAccumMs: 0 }, at(90))).toBe(90_000)
})
it('elapsedMs freezes while paused and subtracts accumulated pause', () => {
// paused at +60s, asked at +90s → frozen at 60s
expect(elapsedMs({ phaseStartedAt: t0, phaseDurationSeconds: 300, phasePausedAt: at(60), phasePausedAccumMs: 0 }, at(90))).toBe(60_000)
// resumed with 30s pause accumulated, asked at +120s → 90s elapsed
expect(elapsedMs({ phaseStartedAt: t0, phaseDurationSeconds: 300, phasePausedAt: null, phasePausedAccumMs: 30_000 }, at(120))).toBe(90_000)
})
it('remainingSeconds goes negative on overtime', () => {
expect(remainingSeconds({ phaseStartedAt: t0, phaseDurationSeconds: 60, phasePausedAt: null, phasePausedAccumMs: 0 }, at(75))).toBe(-15)
})
it('remainingSeconds is null without timer', () => {
expect(remainingSeconds({ phaseStartedAt: null, phaseDurationSeconds: null, phasePausedAt: null, phasePausedAccumMs: 0 }, t0)).toBeNull()
})
it('formatClock renders mm:ss and overtime', () => {
expect(formatClock(305)).toBe('5:05')
expect(formatClock(0)).toBe('0:00')
expect(formatClock(-83)).toBe('+1:23')
})
})
```
- [ ] **Step 2: Run** — FAIL (module not found).
- [ ] **Step 3: Implement:**
```ts
export type PhaseTimerState = {
phaseStartedAt: Date | string | null
phaseDurationSeconds: number | null
phasePausedAt: Date | string | null
phasePausedAccumMs: number
}
export function elapsedMs(t: PhaseTimerState, now: Date = new Date()): number {
if (!t.phaseStartedAt) return 0
const start = new Date(t.phaseStartedAt).getTime()
const end = t.phasePausedAt ? new Date(t.phasePausedAt).getTime() : now.getTime()
return Math.max(0, end - start - t.phasePausedAccumMs)
}
export function remainingSeconds(t: PhaseTimerState, now: Date = new Date()): number | null {
if (!t.phaseStartedAt || t.phaseDurationSeconds == null) return null
return t.phaseDurationSeconds - Math.floor(elapsedMs(t, now) / 1000)
}
export function formatClock(seconds: number): string {
const over = seconds < 0
const abs = Math.abs(seconds)
const m = Math.floor(abs / 60)
const s = abs % 60
return `${over ? '+' : ''}${m}:${s.toString().padStart(2, '0')}`
}
```
- [ ] **Step 4: Run** — PASS. Commit `feat(finale): server-stamped phase timer helper`.
---
### Task 4: Phase machine + notes in `live.ts`
**Files:**
- Modify: `src/server/routers/live.ts`
- Test: `tests/unit/live-phase.test.ts`
- [ ] **Step 1: Write failing tests.** Use `createCaller(liveRouter, adminUser)` + factories (`createTestProgram/Competition/Round/Project`, round status `ROUND_ACTIVE`, `live.start` with 2 projects). Cases:
- `sendToScreens` sets `projectPhase='ON_DECK'`, target project active, timer fields null, `overrideSlide` cleared.
- `startPresentation``PRESENTING`, `phaseStartedAt` set, `phaseDurationSeconds` from input (e.g. 120) else from `round.configJson.presentationDurationMinutes*60` else 300.
- `startQA` after PRESENTING appends a timing-log entry `{projectId, phase:'PRESENTING', configuredSeconds, overranSeconds}` and starts QA timer.
- `openScoring` appends QA entry, phase `SCORING`, timer cleared.
- `pausePhase`/`resumePhase`: after pause, `phasePausedAt` set; resume folds into `phasePausedAccumMs` and clears `phasePausedAt`; pausing twice errors; resuming unpaused errors.
- overtime: startPresentation with `durationSeconds: 1`, manipulate by directly `prisma.liveProgressCursor.update({phaseStartedAt: new Date(Date.now()-10_000)})`, then `startQA` → log entry `overranSeconds >= 9`.
- `setOverrideSlide` sets/clears.
- `saveNote` upserts by (roundId, projectId, userId); second save with same juror overwrites content; `getMyNotes` returns only caller's notes.
- [ ] **Step 2: Run** — FAIL (procedures missing).
- [ ] **Step 3: Implement in `live.ts`.** Shared helper at top of file:
```ts
type TimingEntry = {
projectId: string
phase: 'PRESENTING' | 'QA'
startedAt: string
endedAt: string
configuredSeconds: number | null
overranSeconds: number
}
function closedOutTiming(cursor: {
activeProjectId: string | null
projectPhase: string
phaseStartedAt: Date | null
phaseDurationSeconds: number | null
phasePausedAt: Date | null
phasePausedAccumMs: number
timingLogJson: unknown
}, now: Date): Prisma.InputJsonValue | undefined {
if (!cursor.phaseStartedAt || !cursor.activeProjectId) return undefined
if (cursor.projectPhase !== 'PRESENTING' && cursor.projectPhase !== 'QA') return undefined
const end = cursor.phasePausedAt ?? now
const elapsedSec = Math.max(0, Math.floor((end.getTime() - cursor.phaseStartedAt.getTime() - cursor.phasePausedAccumMs) / 1000))
const entry: TimingEntry = {
projectId: cursor.activeProjectId,
phase: cursor.projectPhase,
startedAt: cursor.phaseStartedAt.toISOString(),
endedAt: now.toISOString(),
configuredSeconds: cursor.phaseDurationSeconds,
overranSeconds: cursor.phaseDurationSeconds == null ? 0 : Math.max(0, elapsedSec - cursor.phaseDurationSeconds),
}
const log = Array.isArray(cursor.timingLogJson) ? (cursor.timingLogJson as TimingEntry[]) : []
return [...log, entry] as unknown as Prisma.InputJsonValue
}
async function getRoundDurations(prisma: PrismaClient, roundId: string) {
const round = await prisma.round.findUniqueOrThrow({ where: { id: roundId } })
const cfg = (round.configJson as Record<string, unknown>) ?? {}
return {
presentation: typeof cfg.presentationDurationMinutes === 'number' ? cfg.presentationDurationMinutes * 60 : 300,
qa: typeof cfg.qaDurationMinutes === 'number' ? cfg.qaDurationMinutes * 60 : 300,
projectOrder: (cfg.projectOrder as string[]) ?? [],
}
}
```
Mutations (all `adminProcedure`, all audit-logged following the file's existing `logAudit` pattern with actions `LIVE_SEND_TO_SCREENS`, `LIVE_PHASE_STARTED`, `LIVE_PHASE_PAUSED`, `LIVE_PHASE_RESUMED`, `LIVE_OVERRIDE_SLIDE`):
```ts
sendToScreens: input {roundId, projectId} cursor findUniqueOrThrow by roundId; durations+order;
index = order.indexOf(projectId) (BAD_REQUEST if -1);
update: { activeProjectId, activeOrderIndex: index, projectPhase: 'ON_DECK',
phaseStartedAt: null, phaseDurationSeconds: null, phasePausedAt: null, phasePausedAccumMs: 0,
overrideSlide: null, timingLogJson: closedOutTiming(cursor, now) }
startPresentation: input {roundId, durationSeconds?: z.number().int().min(10).max(7200).optional()}
update { projectPhase: 'PRESENTING', phaseStartedAt: now,
phaseDurationSeconds: input.durationSeconds ?? durations.presentation,
phasePausedAt: null, phasePausedAccumMs: 0, timingLogJson: closedOutTiming(cursor, now) }
startQA: same shape, phase 'QA', default durations.qa
openScoring: { projectPhase: 'SCORING', phaseStartedAt: null, phaseDurationSeconds: null,
phasePausedAt: null, phasePausedAccumMs: 0, timingLogJson: closedOutTiming(cursor, now) }
pausePhase: PRECONDITION_FAILED if !cursor.phaseStartedAt || cursor.phasePausedAt; set phasePausedAt: now
resumePhase: PRECONDITION_FAILED if !cursor.phasePausedAt;
set phasePausedAccumMs: cursor.phasePausedAccumMs + (now - cursor.phasePausedAt), phasePausedAt: null
setOverrideSlide: input {roundId, slide: z.enum(['welcome','break','deliberation','thanks']).nullable()}
update { overrideSlide: input.slide }
```
Notes procedures (`protectedProcedure`):
```ts
saveNote: input {roundId, projectId, content: z.string().max(20_000)}
prisma.liveNote.upsert({ where: { roundId_projectId_userId: { roundId, projectId, userId: ctx.user.id } },
create: {...}, update: { content } })
getMyNotes: input {roundId} prisma.liveNote.findMany({ where: { roundId, userId: ctx.user.id } })
```
Extend `getCursor` return: spread now includes the new cursor fields automatically (`...cursor`); additionally fetch `orderedProjects` (id, title, teamName, competitionCategory) for the whole `projectOrder` (one `findMany` + reorder in JS) and include `activeProject.competitionCategory` in its select.
- [ ] **Step 4: Run** `npx vitest run tests/unit/live-phase.test.ts` — PASS. Run `npx vitest run tests/unit/auth-public-paths.test.ts` too (regression).
- [ ] **Step 5: Commit** `feat(finale): per-project phase machine, server timers, overtime log, juror notes`.
---
### Task 5: Audience windows + favorite votes in `live-voting.ts`
**Files:**
- Modify: `src/server/routers/live-voting.ts`
- Test: `tests/unit/audience-window.test.ts`
- [ ] **Step 1: Write failing tests.** Setup: program/competition/round (LIVE_FINAL, ROUND_ACTIVE), 3 projects (2 STARTUP, 1 BUSINESS_CONCEPT via `prisma.project.update` setting `competitionCategory`), `round.configJson.projectOrder` set, LiveVotingSession created with `allowAudienceVotes: true`, two AudienceVoter rows (tokens A, B). Cases:
1. `openAudienceWindow({windowKey:'CATEGORY:STARTUP', durationMinutes:5})` → phase OPEN, closesAt ≈ now+5m. Opening again → CONFLICT.
2. `castFavoriteVote` token A for STARTUP project → row created. Re-cast token A other STARTUP project → same row updated (count still 1).
3. Cast for the BUSINESS_CONCEPT project while STARTUP window open → BAD_REQUEST.
4. Set `audienceWindowClosesAt` to past via prisma, cast → PRECONDITION_FAILED (server-side time check, no cron).
5. `closeAudienceWindow` then cast → PRECONDITION_FAILED. Re-open works (new window, key CATEGORY:BUSINESS_CONCEPT) → casting BUSINESS_CONCEPT project OK.
6. `openAudienceWindow({windowKey:'OVERALL'})` with `allowOverallFavorite:false` → FORBIDDEN; after `updateSessionConfig({allowOverallFavorite:true})` → OK; any ordered project castable.
7. IP cap: create 3 voters with ctx ip '1.2.3.4' casting in same window (use `createTestContext` with custom ip — check `tests/setup.ts` signature; if ip not injectable, set `ipAddress` on rows directly and cast the 4th via caller whose ctx.ip is '1.2.3.4') → 4th distinct voter from same IP → TOO_MANY_REQUESTS. A voter updating their own vote from that IP still succeeds.
8. `getFavoriteTallies` returns per-windowKey per-project counts.
9. `getAudienceWindow` (public) reports phase CLOSED once `closesAt` past even without an explicit close, includes eligible projects in order, and `myVote` for a token.
- [ ] **Step 2: Run** — FAIL.
- [ ] **Step 3: Implement.** Zod: `const windowKeySchema = z.enum(['CATEGORY:STARTUP', 'CATEGORY:BUSINESS_CONCEPT', 'OVERALL'])`. Helper in file:
```ts
function windowIsOpen(s: { audiencePhase: string; audienceWindowClosesAt: Date | null }, now = new Date()) {
return s.audiencePhase === 'OPEN' && !!s.audienceWindowClosesAt && now <= s.audienceWindowClosesAt
}
function categoryForKey(key: string): 'STARTUP' | 'BUSINESS_CONCEPT' | null {
return key === 'CATEGORY:STARTUP' ? 'STARTUP' : key === 'CATEGORY:BUSINESS_CONCEPT' ? 'BUSINESS_CONCEPT' : null
}
async function getOrderedFinaleProjects(prisma: PrismaClient, session: { roundId: string | null; projectOrderJson: unknown }) {
let order: string[] = []
if (session.roundId) {
const round = await prisma.round.findUnique({ where: { id: session.roundId } })
order = ((round?.configJson as Record<string, unknown>)?.projectOrder as string[]) ?? []
}
if (order.length === 0) order = (session.projectOrderJson as string[]) ?? []
const projects = await prisma.project.findMany({
where: { id: { in: order } },
select: { id: true, title: true, teamName: true, competitionCategory: true },
})
const byId = new Map(projects.map((p) => [p.id, p]))
return order.map((id) => byId.get(id)).filter(Boolean) as typeof projects
}
```
Procedures:
```ts
openAudienceWindow (adminProcedure): input {sessionId, windowKey: windowKeySchema, durationMinutes: z.number().int().min(1).max(120).default(5)}
session findUniqueOrThrow; if windowIsOpen(session) CONFLICT 'An audience window is already open';
if windowKey==='OVERALL' && !session.allowOverallFavorite FORBIDDEN 'Overall favorite vote is not enabled';
update { audiencePhase:'OPEN', audienceWindowKey: windowKey, audienceWindowOpenedAt: now,
audienceWindowClosesAt: new Date(now + durationMinutes*60_000) }; audit 'AUDIENCE_WINDOW_OPENED'.
closeAudienceWindow (adminProcedure): update { audiencePhase:'CLOSED', audienceWindowKey:null,
audienceWindowOpenedAt:null, audienceWindowClosesAt:null }; audit 'AUDIENCE_WINDOW_CLOSED'.
getAudienceWindow (publicProcedure): input {sessionId, token: z.string().optional()}
session select audiencePhase/windowKey/closesAt/allowAudienceVotes/roundId/projectOrderJson;
const open = windowIsOpen(session); const key = open ? session.audienceWindowKey : null;
projects = open ? getOrderedFinaleProjects(...).filter(p => { const cat = categoryForKey(key!); return cat ? p.competitionCategory === cat : true }) : [];
myVote: if token && key voter by token favoriteVote findUnique by (sessionId, windowKey:key, audienceVoterId) projectId;
return { open, windowKey: key, closesAt: open ? session.audienceWindowClosesAt : null, projects, myVoteProjectId }
castFavoriteVote (publicProcedure): input {sessionId, token, projectId}
voter by token (UNAUTHORIZED if missing/mismatched session);
session findUniqueOrThrow; if !windowIsOpen PRECONDITION_FAILED 'Voting is not open right now';
const key = session.audienceWindowKey!; const cat = categoryForKey(key);
project findUniqueOrThrow select competitionCategory; ordered = getOrderedFinaleProjects(...);
if (!ordered.some(p => p.id === input.projectId)) BAD_REQUEST 'Project is not part of this vote';
if (cat && project.competitionCategory !== cat) BAD_REQUEST 'Project is not in the open category';
existing = favoriteVote findUnique (sessionId, windowKey:key, audienceVoterId: voter.id);
if (!existing && ctx.ip) { const ipCount = await prisma.audienceFavoriteVote.count({ where: { sessionId, windowKey: key, ipAddress: ctx.ip } });
if (ipCount >= 3) TOO_MANY_REQUESTS 'Vote limit reached for this network' }
upsert (update: { projectId, ipAddress: ctx.ip ?? existing?.ipAddress }); return { projectId }
getFavoriteTallies (adminProcedure): input {sessionId}
groupBy ['windowKey','projectId'] _count; plus projects (title/teamName) join; plus per-window total counts.
```
Add `allowOverallFavorite: z.boolean().optional()` to `updateSessionConfig` input (passes straight through to `data`).
- [ ] **Step 4: Run** — PASS. Commit `feat(finale): audience favorite-vote windows with category gating + IP cap`.
---
### Task 6: Jury vote comment + by-round session resolution + my-votes query
**Files:**
- Modify: `src/server/routers/live-voting.ts`
- Test: `tests/unit/live-vote-comment.test.ts`
- [ ] **Step 1: Failing tests:** (a) `vote` with `comment: 'strong pitch'` persists it; re-vote updates it; (b) new `getSessionForVotingByRound({roundId})` returns the same payload shape as `getSessionForVoting` and creates nothing (null when no session); (c) new `getMyFinaleInputs({roundId})` returns caller's LiveVotes (score, criterionScoresJson, comment, projectId) and LiveNotes for the round.
- [ ] **Step 2: Run** — FAIL.
- [ ] **Step 3: Implement:**
- `vote` input gains `comment: z.string().max(5000).optional()`; include in upsert create/update (`comment: input.comment ?? undefined` on update so an omitted comment doesn't erase).
- `getSessionForVotingByRound` (protectedProcedure): `findUnique({ where: { roundId } })`; if null return null; else reuse the body of `getSessionForVoting` (extract a shared `buildVotingPayload(session, ctx)` helper used by both procedures — DRY).
- `getMyFinaleInputs` (protectedProcedure): input `{roundId}` → session by roundId (null-safe) → `liveVote.findMany({ where: { sessionId, userId: ctx.user.id } , select: { projectId, score, criterionScoresJson, comment, votedAt } })` + `liveNote.findMany({ where: { roundId, userId: ctx.user.id } })`. Return `{ votes, notes, session: { id, votingMode, criteriaJson } | null }`.
- [ ] **Step 4: Run** — PASS. Commit `feat(finale): vote comments, by-round session lookup, my-finale-inputs query`.
---
### Task 7: Jury live page rework
**Files:**
- Modify: `src/app/(jury)/jury/competitions/[roundId]/live/page.tsx`
- Modify: `src/components/jury/live-voting-form.tsx` (add comment field — read it first; pass `comment` through `onVoteSubmit`)
No DB logic here; verified by build + Playwright in Task 13. Behaviors:
- [ ] **Step 1: Fix session wiring** — replace `getSessionForVoting({sessionId: params.roundId})` with `getSessionForVotingByRound({roundId: params.roundId})` (poll 2000ms). Keep `live.getCursor` poll at 2000ms (was 5000 — tighten for ceremony).
- [ ] **Step 2: Phase rendering** from `cursor.projectPhase`:
- `ON_DECK`: full-width banner card — "Up next" eyebrow, project title XL, team name; muted note "Presentation starting shortly". No scoring form.
- `PRESENTING` / `QA`: project card with phase badge (`Presentation` / `Q&A`) and live countdown chip using `remainingSeconds`/`formatClock` from `@/lib/live-timer` (tick via 1s `setInterval`, computed from server stamps — never local countdown state); red text when negative. Notes + scoring form below.
- `SCORING`: same but scoring card gets a highlighted ring (`ring-2 ring-[#de0f1e]`) and a "Scoring is open" badge.
- [ ] **Step 3: Persisted notes** — replace local `notes` state: load via `trpc.live.getMyNotes({roundId})`, keep a `Record<projectId, string>` local draft, debounce 800ms → `trpc.live.saveNote.mutate({roundId, projectId, content})`; show "Saved" / "Saving…" microcopy. Notes keyed per active project (switching project switches the note).
- [ ] **Step 4: Comment field** — add optional `Textarea` "Comment (visible to admins with your scores)" inside the voting form submission; include `comment` in `vote` mutation.
- [ ] **Step 5:** `npm run build` green. Commit `feat(finale): phase-aware jury live page with persisted notes + comments`.
---
### Task 8: Reveal controller (backend)
**Files:**
- Modify: `src/server/routers/live-voting.ts`
- Test: `tests/unit/reveal.test.ts`
- [ ] **Step 1: Failing tests:** (a) `saveReveal` upserts steps in DRAFT; (b) `armReveal` requires ≥1 step, DRAFT→ARMED; (c) `revealNext` ARMED→REVEALING idx 0, increments, last step → DONE (idx stays last); (d) `resetReveal` → DRAFT idx -1; (e) **no-leak:** `getCeremonyState` (public, Task 9 — write the test now against the procedure added there if sequencing demands; otherwise assert via a `getPublicReveal` helper) returns only steps `0..currentStepIndex`, empty when ARMED, none when DRAFT.
- [ ] **Step 2: Run** — FAIL.
- [ ] **Step 3: Implement.** Step schema:
```ts
const revealStepSchema = z.object({
kind: z.enum(['category-intro', 'place', 'audience-award', 'overall-favorite', 'thanks']),
category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
place: z.number().int().min(1).max(10).optional(),
projectId: z.string().optional(),
title: z.string().max(200).optional(), // resolved display strings, stored denormalized
subtitle: z.string().max(300).optional(),
})
```
```ts
saveReveal (adminProcedure): {sessionId, steps: z.array(revealStepSchema).max(50)}
revealState upsert by sessionId { stepsJson: steps, status: 'DRAFT', currentStepIndex: -1 }
armReveal (adminProcedure): requires existing DRAFT with steps.length>0 status 'ARMED'
revealNext (adminProcedure): ARMED { status:'REVEALING', currentStepIndex: 0 };
REVEALING idx+1; if idx+1 === steps.length-1 also status 'DONE'
// careful: advance then check — newIndex = currentStepIndex + 1; clamp to steps.length-1;
// status = newIndex >= steps.length - 1 ? 'DONE' : 'REVEALING'
resetReveal (adminProcedure): { status:'DRAFT', currentStepIndex: -1 }
getRevealAdmin (adminProcedure): full state incl. all steps (for preview)
```
Audit-log arm/next/reset with action names `REVEAL_ARMED`, `REVEAL_ADVANCED`, `REVEAL_RESET`.
- [ ] **Step 4: Run** — PASS. Commit `feat(finale): results reveal controller with step-through state`.
---
### Task 9: Public ceremony state endpoint
**Files:**
- Modify: `src/server/routers/live-voting.ts`
- Test: extend `tests/unit/reveal.test.ts` + `tests/unit/audience-window.test.ts` no-leak/shape cases
- [ ] **Step 1: Failing test:** `getCeremonyState({roundId})` (publicProcedure) returns `{ overrideSlide, phase: {projectPhase, phaseStartedAt, phaseDurationSeconds, phasePausedAt, phasePausedAccumMs}, activeProject: {title, teamName, competitionCategory} | null, audience: { open, windowKey, closesAt, voteCount }, reveal: { status, steps: <revealed only>, currentStepIndex } | null, programName }`. Assert: never includes scores, never includes un-revealed steps, includes audience voteCount for the open window only.
- [ ] **Step 2: Run** — FAIL.
- [ ] **Step 3: Implement** — compose from `liveProgressCursor.findUnique({roundId})`, session by roundId, `audienceFavoriteVote.count({sessionId, windowKey})` when open, `revealState` (slice steps `0..currentStepIndex` only when REVEALING/DONE; `[]` when ARMED; null when DRAFT/absent). One procedure, ~60 lines.
- [ ] **Step 4: Run** — PASS. Commit `feat(finale): public ceremony-state endpoint for big screen`.
---
### Task 10: Deliberation jury completion
**Files:**
- Modify: `src/app/(jury)/jury/competitions/deliberation/[sessionId]/page.tsx`
- Modify: `src/server/services/deliberation.ts` (only if `getSessionWithVotes` lacks project list — verify first)
- Modify: `src/server/routers/deliberation.ts` (add projects to getSession payload if needed)
- Read first: `src/components/jury/deliberation-ranking-form.tsx`
- Test: `tests/unit/deliberation-jury-wiring.test.ts`
- [ ] **Step 1: Investigate** `getSessionWithVotes` — confirm what `session.participants[].user` contains (expect JuryGroupMember incl. `user`), and where the rank-able project list comes from (`session.results` is empty before finalize — the form currently gets `[]`!). Decide: extend `getSessionWithVotes` to include `projects` = projects of the session's round + category (via `ProjectRoundState` where `roundId`, project `competitionCategory === session.category`), selecting id/title/teamName.
- [ ] **Step 2: Failing test:** caller = juror user who is a JuryGroupMember + DeliberationParticipant; `deliberation.getSession` exposes `projects` (non-empty pre-finalize) and participant rows that let the client resolve `juryMemberId`; `submitVote` with that `juryMemberId` succeeds and `getSession` then shows the vote (`hasVoted` derivable). Also assert a juror cannot submit with another member's `juryMemberId` (existing enforcement — pin it).
- [ ] **Step 3: Implement service/router change**, run test — PASS.
- [ ] **Step 4: Fix the page:**
```ts
const { data: me } = trpc.user.me.useQuery() // or useSession() — match codebase pattern (check src for existing usage)
const myParticipant = session?.participants?.find((p: any) => p.user?.user?.id === me?.id)
const juryMemberId = myParticipant?.user?.id ?? null // JuryGroupMember.id
const hasVoted = !!session?.votes?.some((v: any) => v.juryMember?.user?.id === me?.id)
```
Pass `projects={session.projects}` to `DeliberationRankingForm`. Submit all votes in ONE call sequence with `juryMemberId`; disable submit when `!juryMemberId` with explanatory text ("You are not a participant of this deliberation").
- [ ] **Step 5: Context panels** (below the ranking form, one collapsible card per project): my finale criteria scores + comment from `trpc.liveVoting.getMyFinaleInputs({roundId: session.roundId})` (criteria labels from `session` payload's criteriaJson), editable via the same `LiveVotingForm` in a dialog (submits `liveVoting.vote` — works because session status check is `IN_PROGRESS`; **verify**: if finale session will be COMPLETED by deliberation time, relax `vote`'s status guard to allow `IN_PROGRESS | PAUSED` and gate `currentProjectId` check to only apply when phase-voting — simplest: allow voting for any ordered project when `round.roundType === 'DELIBERATION'`-linked… **Decision:** add `allowRevote: true` behavior — `vote` accepts any `projectId` in the finale order when the session status is `IN_PROGRESS` or `PAUSED`; keep the `currentProjectId` equality check ONLY when `projectPhase` voting is live i.e. when the cursor's active project equals the voted project OR session.status === 'PAUSED'. Implement as: skip the `currentProjectId !== input.projectId` check when `input.projectId` is in the session's project order and the cursor for the round is in `SCORING` or session is `PAUSED`. Write a unit test for this relaxation.) Also show my `LiveNote` per project, and a link row to the finals documents page (route: check `src/app/(jury)` for the finals docs page added 2026-06-09 — link to it with the project preselected if supported, else plain link).
- [ ] **Step 6:** Tests + `npm run build` green. Commit `feat(finale): working jury deliberation flow with finale-score review and notes`.
---
### Task 11: Admin control panel revamp
**Files:**
- Modify: `src/components/admin/live/live-control-panel.tsx` (becomes orchestrator)
- Create: `src/components/admin/live/run-order-list.tsx`, `phase-controls.tsx`, `audience-window-panel.tsx`, `timing-log-card.tsx`, `reveal-panel.tsx`
- Modify: `package.json` (add `qrcode.react`)
- Find the admin page hosting `LiveControlPanel` (grep usage) — ensure it passes `roundId` + `competitionId` and has room for the new layout (2-col grid on lg).
Pure UI — verified via Playwright in Task 13. Behaviors per component:
- [ ] **Step 1:** `npm i qrcode.react`.
- [ ] **Step 2: `run-order-list.tsx`** — props `{roundId}`; uses `live.getCursor` data (`orderedProjects`, `activeProjectId`, `activeOrderIndex`). Groups rows under `BUSINESS_CONCEPT` / `STARTUP` headings (preserving global order); each row: index, title, teamName, category dot, ▲▼ buttons (swap in `projectOrder`, call `live.reorder`), and a "Send to screens" button (`live.sendToScreens`). Active row highlighted; ON_DECK row shows "on deck" badge.
- [ ] **Step 3: `phase-controls.tsx`** — props `{roundId}`. Shows active project + phase badge; one primary button for the next transition (ON_DECK→"Start presentation", PRESENTING→"Start Q&A", QA→"Open scoring", SCORING→"Send next project" which calls `sendToScreens` with the next project in order); secondary buttons for pause/resume; the big server-derived countdown (`remainingSeconds`/`formatClock`, 1s tick, `text-red-600 animate-pulse` when negative with "OVER" label); duration override `Input` (minutes, prefilled from round config) applied to the next start call. Keep legacy session pause/resume (cursor.isPaused) as a small row.
- [ ] **Step 4: `audience-window-panel.tsx`** — props `{roundId}`. Resolves session via `liveVoting.getSession({roundId})`. Buttons "Open vote — Business Concepts" / "Open vote — Startups" / "Open vote — Overall favorite" (last disabled unless `allowOverallFavorite`; a `Switch` toggles it via `updateSessionConfig`), shared duration `Input` (default 5). When open: countdown, live vote count (`getFavoriteTallies` poll 3s — render per-window totals; per-project tallies in a collapsible "Tallies (admin only)"), "Close now" `destructive` button. "Show QR" button → `Dialog` with `<QRCodeSVG value={origin + '/vote/competition/' + roundId} size={420}/>` + the URL printed beneath.
- [ ] **Step 5: `timing-log-card.tsx`** — renders `cursor.timingLogJson` rows: project title (lookup from orderedProjects), phase, configured vs actual, overran chip (red `+m:ss`) when `overranSeconds > 0`.
- [ ] **Step 6: `reveal-panel.tsx`** — props `{roundId}`. "Compose from results" button: pulls `liveVoting.getResults({sessionId})`, `getFavoriteTallies`, and `deliberation.listSessions({competitionId})` → for each category with a finalized deliberation use its results order, else fall back to jury `getResults` order filtered by category; builds default steps (category-intro → places 3,2,1 → audience-award per category → overall-favorite if tallies exist → thanks) with resolved `title` (team/project name) and `subtitle` ("3rd place — Business Concepts" etc.); shows editable preview list (delete/reorder steps); "Save draft" → `saveReveal`. Then "Arm" (confirm dialog: "Big screen will switch to Results mode"), "Reveal next" (primary, shows `currentStepIndex+1 / steps.length`), "Reset". Show current step preview text so the admin always knows what fires next.
- [ ] **Step 7:** Compose all into `live-control-panel.tsx` (left col: phase-controls + run-order-list; right col: audience-window-panel + timing-log-card + reveal-panel). `npm run build` green. Commit `feat(finale): admin ceremony control panel — phases, run order, audience windows, QR, reveal`.
---
### Task 12: Audience voting page + big-screen ceremony page
**Files:**
- Modify: `src/app/(public)/vote/competition/[roundId]/page.tsx` (read existing first; rework content)
- Create: `src/app/(public)/live/ceremony/[roundId]/page.tsx`
- Create: `src/components/public/ceremony/` (slides: `ceremony-shell.tsx`, `presentation-slide.tsx`, `audience-vote-slide.tsx`, `reveal-slide.tsx`, `static-slide.tsx`)
**Invoke the `frontend-design` skill before building these two surfaces** — the reveal especially must be projector-gorgeous (spec §9): Montserrat 700, dark-blue `#053d57` field, red `#de0f1e` accent, `motion` (v11, import from `'motion/react'`) AnimatePresence transitions, confetti-grade flourish on 1st place + audience award, 16:9-safe, high contrast, no text below ~32px effective.
- [ ] **Step 1: Audience page** — resolve session: add tiny public procedure `liveVoting.getAudienceContextByRound({roundId})` returning `{sessionId, allowAudienceVotes, programName, roundName}` (5 lines, include in Task 5's test file as a shape assertion). Page flow: on mount ensure token in `localStorage['mopc-audience-' + sessionId]` else `registerAudienceVoter` → store. Poll `getAudienceWindow({sessionId, token})` every 3s. States: **waiting** (brand header, "Voting opens after the presentations — keep this page open", subtle wave animation), **open** (windowKey title — "Pick your favorite Business Concept", big tappable project cards (title + team), selected ring, confirm button → `castFavoriteVote`, then **voted** state: green check, "Vote recorded — you can change it until voting closes", countdown chip, tap-again-to-change), **closed** ("Voting is closed — thanks!"). Friendly error toast for IP-cap rejection. Mobile-first, thumb-sized targets, zero instructions needed.
- [ ] **Step 2: Ceremony page**`'use client'`; poll `liveVoting.getCeremonyState({roundId})` every 2s; full-screen `ceremony-shell` (fixed inset-0, `bg-[#053d57]`, MOPC wordmark small top-left, no nav chrome). Render precedence exactly: overrideSlide → reveal (ARMED: "Results" splash; REVEALING/DONE: `reveal-slide` for `steps[currentStepIndex]` with AnimatePresence between steps) → audience window open (`audience-vote-slide`: giant centered QR (white tile, rounded), "Vote for your favorite …", mm:ss countdown, "N votes cast" ticker) → cursor phase (ON_DECK: "Up next" + team; PRESENTING/QA: team name hero + phase label + huge countdown `formatClock`, red glow when negative; SCORING: "The jury is scoring" interstitial) → welcome slide. 1s local tick for countdowns computed from server stamps.
- [ ] **Step 3: Reveal slide details**`place` step: eyebrow ("3rd place — Startups"), team name scales in (motion spring, `initial={{opacity:0, y:40, scale:0.9}}`), 1st place gets gold treatment + confetti burst (CSS/motion particles — ~40 absolutely-positioned animated divs, no new dep); `audience-award`/`overall-favorite`: red accent treatment "Audience Choice"; `category-intro` and `thanks`: typographic full-bleed statements.
- [ ] **Step 4:** `npm run build` green. Commit `feat(finale): audience voting page + big-screen ceremony view with animated reveal`.
---
### Task 13: End-to-end verification + tally audit
**Files:**
- Test: `tests/unit/live-results-tally.test.ts`
- All previous files (fixes as found)
- [ ] **Step 1: Tally audit tests**`getResults`: 2 jury voters scoring 2 projects (criteria mode: assert weighted normalization matches hand-computed values), audienceWeight 0 default keeps jury-only ordering; tie detection fires on equal totals; `getFavoriteTallies` counts match casts. Run — PASS (fix `getResults` if hand-computed values disagree; document any fix in the commit).
- [ ] **Step 2: Full suite** `npx vitest run` — all green. `npm run typecheck` and `npm run build` — green.
- [ ] **Step 3: Manual drive (Playwright MCP against dev server), screenshots at each stop:** seed/identify a LIVE_FINAL round with projects in both categories → admin: start live session, send project to screens, start presentation (1 min override), watch countdown go red, start Q&A, open scoring → jury (second context): see ON_DECK→phases follow along, write a note, refresh (note persists), submit criteria scores + comment → admin: open Business-Concepts audience window → **clean browser context** (NOT the logged-in profile): load `/vote/competition/[roundId]`, cast favorite, change vote, see voted state → ceremony page shows QR + count → close window → compose reveal from results, arm, step through all steps on ceremony page → deliberation: create session, open voting, juror ranks (verify the Task 10 fix), close, aggregate, adminDecide override, finalize.
- [ ] **Step 4: Public-route curl checks** for `/vote/competition/<id>`, `/live/ceremony/<id>`, `/live-scores/<id>` — 200, no login redirect.
- [ ] **Step 5: Fix everything found; re-run suite; commit** `test(finale): tally audit + e2e ceremony verification fixes`.
---
### Task 14 (STRETCH — only if all above is done and verified): Live ranking mode toggle
Skip unless time clearly permits. Admin toggle on the session (`votingMode: 'ranking'`), juror drag-rank of seen-so-far projects persisted to a new `LiveRank` model, results by Borda. **Do not start this before Task 13 is fully green.**
---
## Execution ground rules
- Commit after every task (or sub-step where marked); never push without `npm run build` green.
- Local dev DB only tonight; prod deploy is a separate explicit step with the user (memory: backup first, never `docker compose down -v`).
- If a step's investigation contradicts this plan (shapes, routes, component props), trust the code, adjust minimally, note the deviation in the commit message.

View File

@@ -0,0 +1,108 @@
# Multiple Hotels + Room Assignments — Design Spec
**Date:** 2026-06-04
**Status:** Approved (design), pending implementation
**Context:** The grand-finale logistics feature currently supports exactly **one hotel per edition** (`Hotel.programId @unique`), with no way to say who stays where. Admins need **multiple hotels** and the ability to assign each confirmed attendee to a hotel — usually a whole team together, but with per-member flexibility — including **room number and check-in/out dates**.
> Separately resolved (not part of this spec): the finalist attendee cap is configurable (Admin → Settings → Edition) and was set to 4 in production; because every confirmation path reads `Program.defaultAttendeeCap` live, this applied retroactively to already-sent confirmation links.
## Goals
- Many hotels per edition (CRUD).
- Assign each **confirmed attendee** to a hotel, with **per-member granularity** and a **"assign whole team"** shortcut.
- Track **room number + check-in/check-out** per attendee.
- Surface each attendee's assignment in their team-facing "My Logistics" view and the travel-confirmed email.
## Non-goals (YAGNI)
- Room-sharing modeling (two attendees can simply share a `roomNumber` string — no explicit room entity).
- Hotel booking/availability/pricing.
- External (non-portal) lunch guests are unrelated and untouched.
## Data model
Mirror the existing `FlightDetail` pattern (a 1:1 detail record per `AttendingMember`).
**`Hotel`** — relax the uniqueness so an edition can have many:
```prisma
model Hotel {
id String @id @default(cuid())
programId String // was @unique — now many hotels per edition
name String
address String? @db.Text
link String?
notes String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
stays HotelStay[]
@@index([programId])
}
```
**`HotelStay`** (new, 1:1 with `AttendingMember`):
```prisma
model HotelStay {
id String @id @default(cuid())
attendingMemberId String @unique
hotelId String
roomNumber String?
checkInAt DateTime?
checkOutAt DateTime?
notes String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)
hotel Hotel @relation(fields: [hotelId], references: [id], onDelete: Restrict)
@@index([hotelId])
}
```
- `AttendingMember` gains `hotelStay HotelStay?` (back-relation).
- `onDelete: Restrict` on `hotel` means a hotel with occupants can't be deleted — the router pre-checks and returns a friendly "reassign N occupants first" error.
- Assigning = upsert a `HotelStay`; unassigning = delete it.
## Server (logistics router, `src/server/routers/logistics.ts`)
Replace the 1:1 hotel procedures with a list-based set; add rooming/assignment procedures. All `adminProcedure`, all audited (mirror existing `HOTEL_UPSERT` etc.).
| Procedure | Input | Behavior |
|---|---|---|
| `listHotels` | `{ programId }` | All hotels for the edition + `_count` of stays (occupancy). |
| `createHotel` | `{ programId, name, address?, link?, notes? }` | Create. |
| `updateHotel` | `{ id, name, address?, link?, notes? }` | Update. |
| `deleteHotel` | `{ id }` | Pre-check stays: if >0 → `BAD_REQUEST` "Reassign N occupants first." Else delete. |
| `listRooming` | `{ programId }` | One row per **CONFIRMED** attendee: team (project title), member (user), and their `hotelStay` (hotelId, roomNumber, checkInAt, checkOutAt) or null. Sorted by team then member. |
| `assignStay` | `{ attendingMemberId, hotelId, roomNumber?, checkInAt?, checkOutAt?, notes? }` | Upsert the attendee's `HotelStay`. |
| `assignTeamToHotel` | `{ confirmationId, hotelId, checkInAt?, checkOutAt? }` | For every `AttendingMember` of the confirmation, upsert `HotelStay` with `hotelId` (and optional shared dates); preserve existing `roomNumber`. The "assign whole team" shortcut. |
| `unassignStay` | `{ attendingMemberId }` | Delete the `HotelStay` (no-op safe). |
**Applicant** (`applicant.getMyLogistics`): replace the program-hotel lookup with the caller's `AttendingMember.hotelStay` → return `hotel: { name, address, link, notes } | null` plus `room: { roomNumber, checkInAt, checkOutAt } | null`.
**Email** (`logistics.setFlightStatus` → CONFIRMED, in the `TRAVEL_CONFIRMED` notification metadata): include the attendee's **assigned** hotel + room (from their `HotelStay`) instead of the edition's single hotel. The `getTravelConfirmedTemplate` already accepts a `hotel` object — extend its metadata to carry room/dates.
## Admin UI (`src/components/admin/logistics/hotels-tab.tsx`, reworked)
Two sections:
1. **Hotels** — a list of the edition's hotels; add/edit/delete each (dialog), with an occupancy badge per hotel. Delete shows the "reassign first" error inline.
2. **Rooming** — a table driven by `listRooming`, grouped by team: columns `Member | Hotel (Select) | Room # | Check-in | Check-out`. Each team header has an **"Assign whole team to…"** Select (calls `assignTeamToHotel`). Per-row edits call `assignStay` (debounced on blur for room/dates; immediate on hotel change); clearing the hotel calls `unassignStay`. A **Download CSV** button (mirror the travel/visa export). Empty state when no confirmed attendees yet. shadcn components, visible affordances only (no keyboard shortcuts).
## Team-facing (`src/components/applicant/my-logistics-card.tsx`)
The Hotel section shows the attendee's **assigned** hotel (name/address/link) + **Room** (number) + **check-in/check-out** (Monaco-time labels), or "Hotel details coming soon" when unassigned.
## Migration
- Drop `Hotel_programId_key` unique constraint; add `Hotel_programId_idx`.
- Create `HotelStay` table + FKs (`attendingMemberId` unique → AttendingMember CASCADE; `hotelId` → Hotel RESTRICT) + `HotelStay_hotelId_idx`.
- No data backfill: no `HotelStay` rows exist yet; any existing single `Hotel` row simply becomes the first of many.
- Additive/safe for prod; applied via `prisma migrate deploy` on container start.
## Testing
- Hotel CRUD: create multiple hotels for one program; `deleteHotel` rejected when occupied, succeeds when empty.
- `assignStay` upsert (create then update room/dates); `assignTeamToHotel` assigns all of a team's attendees; `unassignStay` removes.
- `listRooming` returns confirmed attendees with their stay (and null for unassigned).
- `getMyLogistics` returns the assigned hotel + room for the caller; null when unassigned.
- Migration applies cleanly; existing finalist/logistics tests stay green (callers updated from `getHotel`/`upsertHotel`).
## Affected call sites to update (from 1:1 → multi)
- `hotels-tab.tsx` (reworked), `getMyLogistics` (applicant.ts), `setFlightStatus` travel email (logistics.ts), and any other `getHotel`/`upsertHotel` references — grep to confirm before removing the old procedures.

View File

@@ -0,0 +1,199 @@
# External Attendee Dish Self-Selection — Design Spec
**Date:** 2026-06-05
**Status:** Approved (design), pending implementation plan
**Author:** Matt + Claude
## Problem
External lunch attendees (e.g. partners, VIPs added by an admin in the logistics
screen) currently have **no way to choose their own dish**. The admin is expected
to set each external's `dishId` inline. There is no email and no self-service page.
This surfaced when an admin (Marine Jacq-Pietri, `marine@monaco-impact.org`) added
herself as an external attendee expecting to receive an email to pick a dish, and
never got one — because the flow does not exist. Verified in prod 2026-06-05:
the `ExternalAttendee` row exists with `dishId = null`, and no email path targets
externals.
### Current behaviour (verified in code + prod)
- `lunch.createExternal` / `lunch.updateExternal` write the row and send **no email**.
- The only "Pick your lunch dish" email (`sendLunchReminderEmail`) is driven by
`selectUnpickedAttendees`, which queries **`AttendingMember` rows tied to a
CONFIRMED `FinalistConfirmation`** — finalist team members only. Externals are
never in that set.
- `sendLunchRecapEmail` goes to admins + `extraRecipients` only (a manifest, not a picker).
- Externals' dishes are meant to be set by the admin inline via `dishId`.
## Goal
External attendees with an email on file receive a dish-selection email containing
a tokenized link to a dedicated, no-login page where they choose a dish, declare
allergens, and add allergen notes — mirroring the finalist team-member picker.
## Design decisions (locked)
1. **Email trigger:** auto-send on add (when the external has an email) **plus** a
per-row "Resend invite" button in the logistics screen.
2. **Reminders:** unpicked externals are included in both the reminder cron and the
manual "Send reminders" action.
3. **Page fields:** dish + allergens + allergen notes (mirror the member picker).
4. **Dish write precedence:** last-write-wins. Both the inline admin `dishId` field
and the self-service page can write the dish; the admin can always override.
## Reference pattern
This feature mirrors the existing **finalist confirmation flow**:
- `src/lib/finalist-token.ts` — HMAC-signed token (`{ confirmationId, exp }`) via
`NEXTAUTH_SECRET`.
- `src/app/(public)/finalist/confirm/[token]/page.tsx` — public, tokenized, no-login page.
- `finalist.getByToken` / `finalist.confirm` / `finalist.decline``publicProcedure`s.
We replicate this shape for externals.
## Architecture
### 1. Data model
`prisma/schema.prisma``ExternalAttendee` gains one nullable field:
```prisma
inviteSentAt DateTime? // when the dish-selection email was last sent
```
- Drives an "invited ✓" indicator in the admin UI.
- Does **not** gate resends or reminders (those are intentionally repeatable).
- Nullable, so the migration is additive with no backfill.
**No `token` column.** The link is a stateless HMAC-signed token; the external is
loaded by the `externalId` embedded in the verified payload. Trade-off accepted:
individual links can't be revoked without rotating `NEXTAUTH_SECRET` — acceptable
for low-stakes dish picking. (This is the one intentional divergence from the
finalist flow, which stores a DB token for supersede/rotation scenarios that
externals don't have.)
Migration: single additive column. Apply in prod via `prisma migrate deploy`
(runs automatically on container start per the entrypoint). **Do not** run
`migrate dev` against the drifted dev DB — create the migration SQL and use
`db execute` + `migrate resolve` if needed locally.
### 2. Token helper — `src/lib/external-lunch-token.ts`
Mirror `finalist-token.ts`:
```ts
export type ExternalLunchTokenPayload = { externalId: string; exp: number }
export function signExternalLunchToken(payload): string
export function verifyExternalLunchToken(token): ExternalLunchTokenPayload // throws on bad sig / expired
```
- HMAC-SHA256 over base64url payload, `timingSafeEqual` comparison.
- `exp` = `eventAt + 24h` when `eventAt` is set, else `now + 30d`. Generous so the
link outlives the change deadline (the deadline is enforced separately at write time).
### 3. tRPC — `src/server/routers/lunch.ts`
- **`getExternalByToken`** (`publicProcedure`, input `{ token }`):
verify token → load external (+ its `LunchEvent`, ordered `dishes`, current
`dish`/`allergens`/`allergenOther`) → return payload incl. computed
`changeDeadline = eventAt changeCutoffHours`. Throws map to the page's friendly
error states (`expired` / `signature` / not found).
- **`setExternalPick`** (`publicProcedure`, input
`{ token, dishId: string | null, allergens, allergenOther }`):
verify token → if `eventAt` set and `now > changeDeadline``PRECONDITION_FAILED`
→ update the external's `dishId` / `allergens` / `allergenOther`. No audit row
(no authenticated user on a public pick).
- **`sendExternalInvite`** (`adminProcedure`, input `{ externalId }`):
load external (must have an email, else `PRECONDITION_FAILED`) → sign token →
`sendExternalDishInviteEmail(...)` → stamp `inviteSentAt = now` → audit
`LUNCH_EXTERNAL_INVITE_SENT`. Returns the updated row.
- **`createExternal`** (existing, modified): after insert, if `input.email` present,
fire-and-forget send the invite (sign token, send email, stamp `inviteSentAt`)
wrapped in `try/catch`**never throws** (per the "round notifications never
throw" project constraint). A failed send leaves `inviteSentAt = null` so the
admin can resend.
### 4. Email — `src/lib/email.ts`
```ts
export async function sendExternalDishInviteEmail(opts: {
to: string
name: string
eventAt: Date | null
venue: string | null
notes: string | null
changeDeadline: Date | null
pickUrl: string
}): Promise<void>
```
- Uses the existing branded wrapper.
- Subject: `Choose your lunch dish — MOPC grand finale`.
- Body: greeting, event date (Europe/Monaco), venue, optional notes, deadline, CTA
button → `pickUrl`.
- One template serves both the initial invite and reminders.
### 5. Reminders — extend existing flow
- **`src/server/services/lunch-reminders.ts`**: add
`selectUnpickedExternals(prisma, event)` → externals where `email` is set and
`dishId IS NULL` for the event.
- **`src/app/api/cron/lunch-reminders/route.ts`** and **`lunch.sendReminders`**:
after the existing `AttendingMember` loop, also loop unpicked externals and send
`sendExternalDishInviteEmail` with a freshly signed token URL. External links go
to `/lunch/pick/<token>` (not `/applicant`). Per-send errors are caught and
logged, consistent with the member loop.
### 6. Public page — `src/app/(public)/lunch/pick/[token]/page.tsx`
Mirror `(public)/finalist/confirm/[token]/page.tsx`:
- `'use client'`, reads `token` from params, queries `lunch.getExternalByToken`.
- States: loading skeleton; invalid/expired/not-found friendly cards (reuse the
`FriendlyError` pattern with `info@monaco-opc.com` fallback).
- Header card: event date, venue, notes, deadline countdown (reuse `CountdownLabel`).
- Form: dish radio list with dietary-tag badges, allergen checkboxes, allergen-notes
textarea. Submit → `lunch.setExternalPick`.
- Success state: "Your dish is saved", editable until the deadline.
- Past deadline: read-only with "contact an admin" message.
### 7. Admin UI — logistics externals table
- Per-row status chip: `no email` / `Invited` / `Picked`.
- Per-row **Resend invite** button → `lunch.sendExternalInvite` (disabled when no email).
- The inline `dishId` editor stays (admin override path).
### Manifest / recap
No change. `lunch-recap.ts` already includes externals, so self-service picks flow
into the manifest, CSV export, and recap email automatically.
## Edge cases
- **No email on external:** auto-send skipped; resend button disabled; reminders skip.
- **Tampered / expired link:** friendly error card; no data leak.
- **Pick after deadline:** `PRECONDITION_FAILED`; page shows read-only state.
- **Admin and external both set a dish:** last-write-wins (intended).
- **Email added later via `updateExternal`:** no auto-send on update; admin uses the
resend button (keeps `updateExternal` side-effect-free).
## Testing
- Unit: token sign/verify roundtrip + tamper + expiry rejection (`external-lunch-token`).
- Unit: `selectUnpickedExternals` returns only emailed + unpicked externals.
- Integration: `getExternalByToken` happy path; bad/expired token errors.
- Integration: `setExternalPick` happy path; deadline rejection.
- Integration: `createExternal` with email stamps `inviteSentAt` (mocked email send);
without email leaves it null.
## Out of scope
- External attendee decline / RSVP (this is dish-only).
- Reworking the member picker.
- Audience-window / live-voting rework (tracked separately).
```

View File

@@ -0,0 +1,93 @@
# Grand Final: judge-visible document curation + optional revised uploads
**Date:** 2026-06-09
**Status:** Approved (Matt, this session)
**Builds on:** `2026-06-09-grand-final-documents-design.md` and the same-day pivot (commits `f8f2d77`, `8a4184d`)
## Problem
Feedback from the other program admin:
> Jury actually need to see BP + Exec summary + 1min video — the ones they uploaded already. And candidates should be able to upload their PDF pres + video — optional, as some sent it another way.
Two gaps against what is deployed:
1. **Judges see too much.** `listFinalistDocumentsForReview` returns *every* file each finalist team ever submitted (57 per team on prod: Pitch Deck, Intro Video, Executive Summary, Business Plan, Promotional Video, plus any Grand Final uploads). The admin wants judges to see a curated subset (BP + exec summary + 1-min video). There is no way to choose which prior documents are surfaced.
2. **"Optional uploads" mode renders wrong.** The three modes the admin wants are: no new uploads (toggle OFF — works), mandatory uploads (toggle ON + slots required — works), and optional uploads (toggle ON + slots marked not-required). In the all-optional case, `FinalDocumentStatus.allRequiredUploaded` is hardcoded `false` when zero slots are required, so the finalist banner/panel never reach a settled state and the copy implies the docs are mandatory.
Prod facts (verified 2026-06-09 via read-only query): 9 finalist teams, 48 prior files + 3 Grand Final uploads. Every team has all 5 prior doc types. The Business Plan and the 1-minute promo video live under *two different* `FileRequirement` rows depending on the team's path (Semi-Finals Document Submission for 8 teams, Spotlight on Africa Submission Round for 1 team — Blue Fields Company).
## Part 1 — Admin curation of judge-visible documents
### Storage
New optional key on the LIVE_FINAL round's `configJson`:
```ts
reviewVisibleRequirementIds?: string[] // FileRequirement ids from prior rounds
```
Semantics:
- **absent / null** → show all prior files (current behavior; safe default, no migration needed)
- **non-empty array** → show only prior files whose `requirementId` is in the list
- **empty array** → hide all prior files (only Grand Final uploads remain visible)
- **Grand Final round uploads are always shown**, regardless of the selection — they are what the team explicitly submitted for the finale
- Prior files with no `requirementId` (fileType-only) are excluded whenever a selection is active. (All 48 prod files have a requirement, so nothing is lost in practice.)
### Service (`src/server/services/final-documents.ts`)
`listFinalistDocumentsForReview` adds `requirementId` to its file select and applies the filter above using the finale round's `configJson`. No signature change; `ReviewPayload` unchanged.
New helper to power the admin picker: list the distinct prior-round requirement slots referenced by the finalist teams' files — `{ requirementId, name, roundName, fileCount }`, ordered by round sort then name. Derived from the same file query, so the picker only offers slots that actually have files.
### tRPC (`src/server/routers/finalist.ts`, adminProcedure)
- `getReviewDocSettings``{ options: Slot[], selectedIds: string[] | null }` (null = "all" mode)
- `setReviewVisibleRequirements({ requirementIds: string[] | null })` → writes/clears the configJson key (null clears back to "show all"). Audited like `setRevisedUploadSetting`.
### Admin UI
New card "Documents shown to judges" placed next to the existing revised-uploads toggle (`src/components/admin/grand-finale/final-docs-uploads-toggle.tsx`, rendered on the LIVE_FINAL round admin page `src/app/(admin)/admin/rounds/[roundId]/page.tsx`):
- A "Show all submitted documents" master state (the default), and beneath it a checkbox per slot labeled `"<requirement name> — <round name>"` with the file count (e.g. "Business Plan — Semi-Finals Document Submission (8 files)").
- Unchecking the master switches to curated mode with all boxes ticked; the admin then unticks what judges shouldn't see. Re-checking the master clears the selection (back to null/"all").
- Copy notes that Grand Final uploads are always visible to judges.
For the admin's stated goal, they'd switch to curated mode and leave 5 boxes ticked: Executive Summary (Intake), Business Plan (Semi-Finals + Spotlight), Promotional Video (Semi-Finals) and 1 Minute Promotional Video (Spotlight) — judges then see exactly BP + exec summary + 1-min video per team, plus any finale uploads.
## Part 2 — All-optional upload mode fix
`FinalDocumentStatus` (in `final-documents.ts`) gains:
```ts
hasRequired: boolean // any slot with isRequired
allUploaded: boolean // requirements.length > 0 && every slot has a file, required or not
```
`allRequiredUploaded` keeps its current semantics (meaningful only when `hasRequired`). Edge case: if the toggle is ON but no slots are defined at all (`requirements.length === 0`), the banner and panel render nothing — no vacuous "(0 of 0)" complete state.
UI changes:
- **Banner** (`src/components/applicant/final-documents-banner.tsx`): when `hasRequired` is false — title "Upload updated Grand Final documents (optional)", same neutral blue styling, keep per-doc checklist/count/deadline/upload button; green settled state ("Grand Final documents uploaded") only when `allUploaded`.
- **Panel** (`src/components/applicant/final-documents-panel.tsx`, team + mentor variants): "Submitted" badge driven by `hasRequired ? allRequiredUploaded : allUploaded`; description gains "(optional)" when nothing is required.
Reminders need **no change** — verified: the cron and the untargeted manual blast already skip teams with no missing *required* docs, so all-optional mode never nags; an explicitly targeted manual reminder still sends (intentional admin override).
## Out of scope (admin actions in existing UI, not code)
- Flipping `allowFinalistRevisedUploads` ON
- Creating/adjusting the finale upload slots (PDF presentation + 1-min video, "Required" off) in the round's file-requirements editor
- Ticking the curation checkboxes
- Populating the Finals Jury group (still open from the previous ship)
## Testing
Vitest service tests (extend `tests/` final-documents coverage):
- Curation: null selection → all files; selection → only matching prior files + finale uploads always; empty array → finale uploads only; file without requirementId excluded under a selection.
- Picker helper returns distinct slots with correct counts.
- `setReviewVisibleRequirements` round-trips null/array through configJson without clobbering other keys (`allowFinalistRevisedUploads`).
- Status: `hasRequired`/`allUploaded` across mixed, all-optional (0 required), and fully-uploaded fixtures.
## Risks
- **configJson clobbering:** both toggles write the same JSON column — read-modify-write must preserve sibling keys (existing `setRevisedUploadSetting` pattern already does this; reuse it).
- **Stale selection:** if a selected requirement is later deleted, its files simply stop matching; "all" fallback never breaks. No cleanup needed.

View File

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

View File

@@ -0,0 +1,201 @@
# Grand Finale Ceremony System — Design (Option C)
> **Status:** Approved 2026-06-10. Event is 2026-06-11 — build tonight in strict dependency order (see Build Order). Each layer must leave the system complete and operable if work stops there.
>
> Supersedes the operational scope of `2026-04-28-grand-finale-live-voting-rework.md` (the audience-window section of that spec is implemented here as designed).
## Decisions (locked with user)
1. **Jury scoring mode:** criteria scores + optional comment (like prior evaluation rounds). Live ranking mode is a stretch goal only.
2. **Audience votes:** per-category favorite windows always; an **overall-favorite** window exists behind an admin toggle (decided day-of).
3. **Vote gating:** one vote per browser token per window AND max **3 votes per IP per window** (venue NAT tolerance).
4. **Deliberation:** per category (two sessions). Existing backend (FULL_RANKING/Borda, adminDecide override) is used as-is.
5. **Architecture:** Option C = full B scope + big-screen ceremony view + results reveal controller. Big screen is **derived from existing state** — no new session-level phase machine. Only new state: reveal controller + display override slide.
6. **During audience windows the big screen shows vote count only** ("147 votes cast"), never a live per-project tally.
7. **Big-screen results reveal must be visually outstanding** — projector-grade, brand identity (dark blue `#053d57` field, red `#de0f1e` accent, Montserrat), animated transitions.
## Current foundation (verified 2026-06-10)
- `LiveProgressCursor` (cursor: activeProjectId, activeOrderIndex, isPaused) + `live.ts` router (start/jump/reorder/pause/resume/getCursor).
- `LiveVotingSession` / `LiveVote` / `AudienceVoter` + `live-voting.ts` router (criteria voting, importCriteriaFromForm, getResults, registerAudienceVoter/castAudienceVote, public results).
- Jury live page `/jury/competitions/[roundId]/live` follows cursor (poll 5s); notes textarea is **not persisted**; prior-data panel stubbed.
- Admin `live-control-panel.tsx`: prev/next/pause/resume + **client-local fake timer**.
- Public `/live-scores/[sessionId]` scoreboard with SSE.
- Deliberation backend + router 100% complete; jury deliberation page has `juryMemberId=''` and `hasVoted=false` hardcoded (jurors cannot vote).
- `publicPaths` in `auth.config.ts` does **not** include `/vote` or `/live-scores` → audience pages bounce to login. Launch blocker.
---
## 1. Public access (do first)
Add `/vote`, `/live-scores`, `/live/ceremony` to `publicPaths` in `src/lib/auth.config.ts` (wherever publicPaths lives). Verify with `curl -I` (not the logged-in Playwright profile).
## 2. Schema changes (one migration)
```prisma
// LiveProgressCursor — per-project phase + server-stamped timer
projectPhase LivePhase @default(ON_DECK) // ON_DECK | PRESENTING | QA | SCORING
phaseStartedAt DateTime?
phaseDurationSeconds Int?
phasePausedAt DateTime?
phasePausedAccumMs Int @default(0)
timingLogJson Json? // [{projectId, phase, startedAt, endedAt, configuredSeconds, overranSeconds}]
overrideSlide String? // 'welcome' | 'break' | 'deliberation' | 'thanks' | null
enum LivePhase { ON_DECK PRESENTING QA SCORING }
// LiveVotingSession — audience window (locked spec from 2026-04-28 + overall kind)
audiencePhase AudiencePhase @default(CLOSED) // CLOSED | OPEN
audienceWindowKey String? // 'CATEGORY:STARTUP' | 'CATEGORY:BUSINESS_CONCEPT' | 'OVERALL'
audienceWindowOpenedAt DateTime?
audienceWindowClosesAt DateTime?
allowOverallFavorite Boolean @default(false) // admin toggle, decided day-of
enum AudiencePhase { CLOSED OPEN }
// LiveVote — optional overall comment
comment String?
model AudienceFavoriteVote {
id String @id @default(cuid())
sessionId String
windowKey String // matches audienceWindowKey at cast time
projectId String
audienceVoterId String
ipAddress String?
createdAt DateTime @default(now())
@@unique([sessionId, windowKey, audienceVoterId])
@@index([sessionId, windowKey, ipAddress])
}
model LiveNote {
id String @id @default(cuid())
roundId String
projectId String
userId String
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([roundId, projectId, userId])
}
model RevealState {
id String @id @default(cuid())
sessionId String @unique
status String @default("DRAFT") // DRAFT | ARMED | REVEALING | DONE
stepsJson Json // ordered reveal steps, see §8
currentStepIndex Int @default(-1)
}
```
Timer math (shared client+server helper `src/lib/live-timer.ts`): `remaining = phaseDurationSeconds (now phaseStartedAt phasePausedAccumMs)`; negative = overtime, displayed `+m:ss` in red. On every phase transition the server appends a timing-log entry with `overranSeconds = max(0, elapsed configured)`.
## 3. Ceremony control — `live.ts` router + admin panel revamp
New adminProcedure mutations on `live`:
- `sendToScreens({roundId, projectId?})` — advance cursor to project (or next), `projectPhase=ON_DECK`, no timer. This is the "next up" grace.
- `startPresentation({roundId, durationSeconds?})``PRESENTING`, stamp `phaseStartedAt`, duration from round config `presentationDurationMinutes` unless overridden.
- `startQA({roundId, durationSeconds?})` — close out PRESENTING into timing log, start QA timer (`qaDurationMinutes` default).
- `openScoring({roundId})` — close QA into log, `phase=SCORING`, no timer.
- `pausePhase` / `resumePhase` — stamp `phasePausedAt` / fold into `phasePausedAccumMs`.
- `setOverrideSlide({roundId, slide})` — force big-screen slide or clear.
- `getCursor` extended to return phase, timer stamps, timing log, override slide.
Admin panel (`live-control-panel.tsx` revamp):
- Project order list **grouped by category**, current/next highlighted, reorder preserved (existing `reorder` mutation), tap-to-`sendToScreens` any project (handles schedule shuffles).
- Big phase buttons in flow order; live server-derived countdown, red + counting up when over; pause/resume.
- Per-project duration override inputs (pre-filled from config).
- Timing log table (per project: presentation over by X, Q&A over by Y).
- Audience section: window open/close buttons per category + overall (gated by `allowOverallFavorite` toggle), duration picker (default 5 min), live countdown, **live vote count**, "Close now". QR button → full-screen dialog with giant QR (`qrcode.react` or equivalent tiny dep) linking to `/vote/competition/[roundId]`.
- Override-slide buttons: Welcome / Break / Deliberation / Thank you / Clear.
- Reveal section: see §9.
## 4. Jury live page
- Phase-aware: ON_DECK → "Up next: Team X" banner; PRESENTING/QA → project details, same countdown the admin sees, persistent notes; SCORING → scoring form spotlighted (form already available from PRESENTING on — early scorers not blocked).
- **Notes**: `LiveNote` autosave (debounced ~800ms) via new `live.saveNote` / `live.getMyNotes` (juryProcedure). Notes resurface in deliberation.
- Scoring: existing criteria form + new optional **comment** textarea → stored on `LiveVote.comment`. Votes upsert-editable until session COMPLETED.
## 5. Audience voting
`live-voting.ts` additions:
- `openAudienceWindow({sessionId, windowKey, durationMinutes})` (admin) — errors if already OPEN; `OVERALL` requires `allowOverallFavorite`.
- `closeAudienceWindow({sessionId})` (admin) — early close allowed anytime.
- `getAudienceWindow({sessionId})` (public) — phase, windowKey, closesAt, eligible projects (category members or all), my-vote-for-this-window (by token).
- `castFavoriteVote({sessionId, token, projectId})` (public). Server-side gates, in order:
1. `audiencePhase === OPEN`
2. `now <= audienceWindowClosesAt` (source of truth even if no cron closes it)
3. project's category matches windowKey (or OVERALL → any finalist project)
4. token valid for session
5. unique (session, windowKey, voter) — re-vote within open window **updates** the row (change-your-mind allowed while open)
6. IP cap: ≥3 distinct-voter rows for (session, windowKey, ipAddress) → reject with friendly message
- `getFavoriteTallies({sessionId})` (admin) — counts per project per windowKey.
- `setAllowOverallFavorite({sessionId, allow})` (admin).
Audience page `/vote/competition/[roundId]` (rework): scan → auto-`registerAudienceVoter`, token in localStorage → states: **waiting** ("Voting opens after the presentations" + current presenting team name), **open** (category title, project cards, tap → confirm → done, countdown chip), **voted** ("Vote recorded — you can change it until the window closes"), **closed**. Mobile-first, zero-instruction usable.
No cron needed: vote-time + read-time checks enforce the close; window auto-renders as closed everywhere once `closesAt` passes.
## 6. Deliberation completion
- Fix jury page wiring: resolve `juryMemberId` from `session.participants` matching current user; derive `hasVoted` from `session.votes`.
- Add per-project context panels to the jury deliberation page: **my finale scores** (from `LiveVote`, editable inline — edits upsert the same vote, audit-logged; "keep" = do nothing), **my live notes** (`LiveNote`), **document links** (reuse the judge-docs components/links from the finals docs feature).
- Admin: existing create-session (per category), aggregate, runoff, `adminDecide` (manual rankings), finalize, result lock — verify end-to-end, no new build expected.
## 7. Big-screen ceremony view — `/live/ceremony/[roundId]` (public)
Full-screen, no chrome, dark-blue field, Montserrat, brand accents. **Pure derivation** of state (poll ~2s + reuse SSE hook where available):
| State | Display |
|---|---|
| `overrideSlide` set | That slide (Welcome / Break / Deliberation in progress / Thank you) |
| Reveal REVEALING/ARMED | Reveal mode (§9) |
| Audience window OPEN | Giant QR + "Vote for your favorite {category}" + countdown + **vote count only** |
| Cursor ON_DECK | "Up next: Team X" |
| Cursor PRESENTING/QA | Team name, category chip, phase label, large countdown (red overtime) |
| Cursor SCORING | "Jury is scoring" interstitial |
| Nothing active | Welcome slide |
## 8. Reveal controller (admin) — §3 panel section
- "Build reveal" generates `stepsJson` from deliberation results (per category 3rd→2nd→1st) + audience favorite tallies (per-category winners, overall if enabled): `[{kind:'category-intro',category}, {kind:'place',category,place,projectId}, …, {kind:'audience-award',windowKey,projectId}, {kind:'thanks'}]`. Editable/rebuildable while DRAFT (e.g., after adminDecide changes order).
- Admin previews all steps privately. "Arm" → big screen shows a Results splash. "Next" advances `currentStepIndex` one step at a time. "Reset" → back to DRAFT, screen leaves reveal mode.
- Router: `liveVoting.buildReveal`, `armReveal`, `revealNext`, `resetReveal` (admin) + reveal state included in the public ceremony-state query (steps beyond `currentStepIndex` are **never** sent to the public endpoint).
## 9. Reveal visuals (the gorgeous part)
Use the `frontend-design` skill when building. Requirements: cinematic step transitions (place card slides/fades up, 1st-place moment visibly bigger than 3rd/2nd), confetti or equivalent flourish on 1st place and audience award, team name in very large Montserrat 700, category chip, no scores shown unless step includes them, safe on 16:9 projector at distance (high contrast, no small text). Prefer CSS/tailwind animation; adding `framer-motion` is acceptable if it materially raises quality. Must degrade gracefully (refresh mid-reveal lands on current step).
## 10. Results tally audit
- Jury results: existing `getResults` weighted aggregation + tie detection — dedicated tests.
- Audience awards are **counts from `AudienceFavoriteVote`**, kept separate from jury scores (separate awards). `audienceVoteWeight` blending stays available but is NOT used in the reveal unless explicitly configured; default 0.
## Out of scope (explicit)
Live ranking mode for jurors (stretch only, after everything else), automated tie-breaker revotes (admin_decides stands), audience phones showing live scores (vote-only page), session-level ceremony phase machine.
## Build order (cut line moves down, never breaks)
1. publicPaths fix + schema migration
2. Audience windows + favorite votes + IP cap + audience page + QR (audience system complete)
3. Phase model + server timers + admin panel revamp (ceremony operable)
4. Jury page phases + persisted notes + vote comments (jury complete)
5. Deliberation wiring fix + context panels (deliberation complete)
6. Big-screen ceremony view (derived states + override slides)
7. Reveal controller + reveal visuals
8. Tally audit tests → stretch: live ranking toggle
Each layer: vitest tests + `npm run build` green before the next.
## Test matrix (vitest, follows tests/helpers.ts factory pattern)
- Window: open/cast OK; wrong-category reject; cast after closesAt reject (no cron); early close reject; re-open works; OVERALL requires toggle.
- Favorite votes: one per token per window; re-vote updates while open; IP cap at 3 distinct voters; tallies correct.
- Phases: transition sequence; timing log entries with correct overranSeconds; pause/resume accumulator math.
- Notes: upsert per (round, project, user).
- Deliberation: juryMemberId resolution, hasVoted, vote→aggregate→finalize with real juror identity.
- Reveal: build from results, step advance, public endpoint never leaks un-revealed steps.
- Results: weighted jury aggregation + tie detection regression tests.

10
package-lock.json generated
View File

@@ -65,6 +65,7 @@
"openai": "^6.16.0",
"papaparse": "^5.4.1",
"pdf-parse": "^2.4.5",
"qrcode.react": "^4.2.0",
"react": "^19.0.0",
"react-day-picker": "^9.13.0",
"react-dom": "^19.0.0",
@@ -13417,6 +13418,15 @@
],
"license": "MIT"
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",

View File

@@ -79,6 +79,7 @@
"openai": "^6.16.0",
"papaparse": "^5.4.1",
"pdf-parse": "^2.4.5",
"qrcode.react": "^4.2.0",
"react": "^19.0.0",
"react-day-picker": "^9.13.0",
"react-dom": "^19.0.0",

View File

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

View File

@@ -0,0 +1,33 @@
-- DropIndex
DROP INDEX "Hotel_programId_key";
-- CreateTable
CREATE TABLE "HotelStay" (
"id" TEXT NOT NULL,
"attendingMemberId" TEXT NOT NULL,
"hotelId" TEXT NOT NULL,
"roomNumber" TEXT,
"checkInAt" TIMESTAMP(3),
"checkOutAt" TIMESTAMP(3),
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HotelStay_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "HotelStay_attendingMemberId_key" ON "HotelStay"("attendingMemberId");
-- CreateIndex
CREATE INDEX "HotelStay_hotelId_idx" ON "HotelStay"("hotelId");
-- CreateIndex
CREATE INDEX "Hotel_programId_idx" ON "Hotel"("programId");
-- AddForeignKey
ALTER TABLE "HotelStay" ADD CONSTRAINT "HotelStay_attendingMemberId_fkey" FOREIGN KEY ("attendingMemberId") REFERENCES "AttendingMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HotelStay" ADD CONSTRAINT "HotelStay_hotelId_fkey" FOREIGN KEY ("hotelId") REFERENCES "Hotel"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- Add inviteSentAt to ExternalAttendee for dish-selection email tracking
ALTER TABLE "ExternalAttendee" ADD COLUMN "inviteSentAt" TIMESTAMP(3);

View File

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

View File

@@ -0,0 +1,104 @@
-- CreateEnum
CREATE TYPE "LivePhase" AS ENUM ('ON_DECK', 'PRESENTING', 'QA', 'SCORING');
-- CreateEnum
CREATE TYPE "AudiencePhase" AS ENUM ('CLOSED', 'OPEN');
-- AlterTable
ALTER TABLE "LiveProgressCursor" ADD COLUMN "overrideSlide" TEXT,
ADD COLUMN "phaseDurationSeconds" INTEGER,
ADD COLUMN "phasePausedAccumMs" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "phasePausedAt" TIMESTAMP(3),
ADD COLUMN "phaseStartedAt" TIMESTAMP(3),
ADD COLUMN "projectPhase" "LivePhase" NOT NULL DEFAULT 'ON_DECK',
ADD COLUMN "timingLogJson" JSONB;
-- AlterTable
ALTER TABLE "LiveVote" ADD COLUMN "comment" TEXT;
-- AlterTable
ALTER TABLE "LiveVotingSession" ADD COLUMN "allowOverallFavorite" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "audiencePhase" "AudiencePhase" NOT NULL DEFAULT 'CLOSED',
ADD COLUMN "audienceWindowClosesAt" TIMESTAMP(3),
ADD COLUMN "audienceWindowKey" TEXT,
ADD COLUMN "audienceWindowOpenedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "AudienceFavoriteVote" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"windowKey" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"audienceVoterId" TEXT NOT NULL,
"ipAddress" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AudienceFavoriteVote_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LiveNote" (
"id" TEXT NOT NULL,
"roundId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LiveNote_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RevealState" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"stepsJson" JSONB NOT NULL,
"currentStepIndex" INTEGER NOT NULL DEFAULT -1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RevealState_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AudienceFavoriteVote_sessionId_windowKey_ipAddress_idx" ON "AudienceFavoriteVote"("sessionId", "windowKey", "ipAddress");
-- CreateIndex
CREATE INDEX "AudienceFavoriteVote_sessionId_windowKey_projectId_idx" ON "AudienceFavoriteVote"("sessionId", "windowKey", "projectId");
-- CreateIndex
CREATE UNIQUE INDEX "AudienceFavoriteVote_sessionId_windowKey_audienceVoterId_key" ON "AudienceFavoriteVote"("sessionId", "windowKey", "audienceVoterId");
-- CreateIndex
CREATE INDEX "LiveNote_userId_idx" ON "LiveNote"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "LiveNote_roundId_projectId_userId_key" ON "LiveNote"("roundId", "projectId", "userId");
-- CreateIndex
CREATE UNIQUE INDEX "RevealState_sessionId_key" ON "RevealState"("sessionId");
-- AddForeignKey
ALTER TABLE "AudienceFavoriteVote" ADD CONSTRAINT "AudienceFavoriteVote_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "LiveVotingSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AudienceFavoriteVote" ADD CONSTRAINT "AudienceFavoriteVote_audienceVoterId_fkey" FOREIGN KEY ("audienceVoterId") REFERENCES "AudienceVoter"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AudienceFavoriteVote" ADD CONSTRAINT "AudienceFavoriteVote_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LiveNote" ADD CONSTRAINT "LiveNote_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LiveNote" ADD CONSTRAINT "LiveNote_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LiveNote" ADD CONSTRAINT "LiveNote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RevealState" ADD CONSTRAINT "RevealState_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "LiveVotingSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -347,6 +347,7 @@ model User {
resourceAccess ResourceAccess[]
submittedProjects Project[] @relation("ProjectSubmittedBy")
liveVotes LiveVote[]
liveNotes LiveNote[]
// Team membership & mentorship
teamMemberships TeamMember[]
@@ -506,7 +507,7 @@ model Program {
// Grand-finale logistics
finalistSlotQuotas FinalistSlotQuota[]
waitlistEntries WaitlistEntry[]
hotel Hotel?
hotels Hotel[]
lunchEvent LunchEvent?
@@unique([name, year])
@@ -657,6 +658,10 @@ model Project {
finalistConfirmation FinalistConfirmation?
externalLunchAttendees ExternalAttendee[]
// Grand-finale ceremony
audienceFavoriteVotes AudienceFavoriteVote[]
liveNotes LiveNote[]
@@index([programId])
@@index([status])
@@index([tags])
@@ -1188,13 +1193,22 @@ model LiveVotingSession {
audienceRequireId Boolean @default(false) // Require email/phone for audience
audienceVotingDuration Int? // Minutes (null = same as jury)
// Audience favorite-vote window (grand finale)
audiencePhase AudiencePhase @default(CLOSED)
audienceWindowKey String? // 'CATEGORY:STARTUP' | 'CATEGORY:BUSINESS_CONCEPT' | 'OVERALL'
audienceWindowOpenedAt DateTime?
audienceWindowClosesAt DateTime?
allowOverallFavorite Boolean @default(false) // admin toggle, decided day-of
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
round Round? @relation(fields: [roundId], references: [id], onDelete: Cascade)
round Round? @relation(fields: [roundId], references: [id], onDelete: Cascade)
votes LiveVote[]
audienceVoters AudienceVoter[]
favoriteVotes AudienceFavoriteVote[]
revealState RevealState?
@@index([status])
}
@@ -1211,6 +1225,9 @@ model LiveVote {
// Criteria scores (used when votingMode="criteria")
criterionScoresJson Json? @db.JsonB // { [criterionId]: score } - null for simple mode
// Optional overall comment from the juror (grand finale)
comment String? @db.Text
// Audience voter link
audienceVoterId String?
@@ -1238,13 +1255,81 @@ model AudienceVoter {
createdAt DateTime @default(now())
// Relations
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
votes LiveVote[]
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
votes LiveVote[]
favoriteVotes AudienceFavoriteVote[]
@@index([sessionId])
@@index([token])
}
// One pick-one-favorite vote per audience member per voting window.
// windowKey snapshots LiveVotingSession.audienceWindowKey at cast time so
// per-category and overall votes coexist in one table.
model AudienceFavoriteVote {
id String @id @default(cuid())
sessionId String
windowKey String // 'CATEGORY:STARTUP' | 'CATEGORY:BUSINESS_CONCEPT' | 'OVERALL'
projectId String
audienceVoterId String
ipAddress String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
audienceVoter AudienceVoter @relation(fields: [audienceVoterId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@unique([sessionId, windowKey, audienceVoterId])
@@index([sessionId, windowKey, ipAddress])
@@index([sessionId, windowKey, projectId])
}
// Per-juror per-project free-text notes taken during the live ceremony.
// Resurfaces during deliberation.
model LiveNote {
id String @id @default(cuid())
roundId String
projectId String
userId String
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([roundId, projectId, userId])
@@index([userId])
}
// Admin-driven results reveal for the big-screen ceremony view.
// Steps beyond currentStepIndex are never exposed publicly.
model RevealState {
id String @id @default(cuid())
sessionId String @unique
status String @default("DRAFT") // DRAFT | ARMED | REVEALING | DONE
stepsJson Json @db.JsonB // RevealStep[]
currentStepIndex Int @default(-1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
}
enum LivePhase {
ON_DECK
PRESENTING
QA
SCORING
}
enum AudiencePhase {
CLOSED
OPEN
}
// =============================================================================
// TEAM MEMBERSHIP
// =============================================================================
@@ -2157,6 +2242,15 @@ model LiveProgressCursor {
activeOrderIndex Int @default(0)
isPaused Boolean @default(false)
// Per-project ceremony phase + server-stamped timer (grand finale)
projectPhase LivePhase @default(ON_DECK)
phaseStartedAt DateTime?
phaseDurationSeconds Int?
phasePausedAt DateTime?
phasePausedAccumMs Int @default(0)
timingLogJson Json? @db.JsonB // [{projectId, phase, startedAt, endedAt, configuredSeconds, overranSeconds}]
overrideSlide String? // big-screen override: 'welcome' | 'break' | 'deliberation' | 'thanks'
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -2283,6 +2377,7 @@ model Round {
notificationLogs NotificationLog[]
cohorts Cohort[]
liveCursor LiveProgressCursor?
liveNotes LiveNote[]
@@unique([competitionId, slug])
@@unique([competitionId, sortOrder])
@@ -2761,6 +2856,8 @@ model FinalistConfirmation {
declinedAt DateTime?
declineReason String? // optional free-text on decline
expiredAt DateTime?
reminderSentAt DateTime? // set when the pre-deadline reminder is sent (cron)
finalDocsReminderSentAt DateTime? // set when the grand-final document-upload reminder is sent (cron)
promotedFromWaitlistEntryId String? @unique // null for original finalists
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -2785,6 +2882,7 @@ model AttendingMember {
flightDetail FlightDetail?
visaApplication VisaApplication?
lunchPick MemberLunchPick?
hotelStay HotelStay?
@@unique([confirmationId, userId])
@@index([userId])
@@ -2801,7 +2899,7 @@ enum FlightDetailStatus {
model Hotel {
id String @id @default(cuid())
programId String @unique // 1:1 — one hotel per edition
programId String // many hotels per edition
name String
address String? @db.Text
link String? // external URL to hotel page / booking confirmation
@@ -2809,7 +2907,28 @@ model Hotel {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
stays HotelStay[]
@@index([programId])
}
/// Per-attendee hotel/room assignment (1:1 with AttendingMember, mirrors FlightDetail).
model HotelStay {
id String @id @default(cuid())
attendingMemberId String @unique
hotelId String
roomNumber String?
checkInAt DateTime?
checkOutAt DateTime?
notes String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)
hotel Hotel @relation(fields: [hotelId], references: [id], onDelete: Restrict)
@@index([hotelId])
}
model FlightDetail {
@@ -2960,6 +3079,7 @@ model ExternalAttendee {
dishId String?
allergens Allergen[] @default([])
allergenOther String?
inviteSentAt DateTime? // when the dish-selection email was last sent (null = never invited)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -214,6 +214,78 @@ const NOTIFICATION_EMAIL_SETTINGS = [
sendEmail: true,
},
// Logistics notifications
{
notificationType: 'FINALIST_CONFIRMED',
category: 'logistics',
label: 'Finalist Confirmed',
description: 'Admin alert when a team confirms their grand-finale attendance',
sendEmail: true,
},
{
notificationType: 'FINALIST_DECLINED',
category: 'logistics',
label: 'Finalist Declined',
description: 'Admin alert when a team declines or an admin declines their finalist slot',
sendEmail: true,
},
{
notificationType: 'FINALIST_EXPIRED',
category: 'logistics',
label: 'Finalist Confirmation Expired',
description: 'Admin alert when a pending confirmation passes its deadline without a response',
sendEmail: true,
},
{
notificationType: 'FINALIST_WAITLIST_PROMOTED',
category: 'logistics',
label: 'Waitlist Promoted',
description: 'Admin alert when a waitlisted team is promoted to a confirmed finalist slot',
sendEmail: true,
},
{
notificationType: 'FINALIST_REMINDER',
category: 'logistics',
label: 'Confirmation Reminder',
description: 'Reminder email to the team lead when the confirmation deadline is approaching',
sendEmail: true,
},
{
notificationType: 'FINALIST_WITHDRAWN',
category: 'logistics',
label: 'Finalist Slot Withdrawn',
description: 'Notification to the team when their confirmed grand-finale slot is withdrawn by an admin',
sendEmail: true,
},
{
notificationType: 'TRAVEL_CONFIRMED',
category: 'logistics',
label: 'Travel Confirmed',
description: 'Email to the attendee when their flight and travel details are confirmed',
sendEmail: true,
},
{
notificationType: 'VISA_STATUS_UPDATE',
category: 'logistics',
label: 'Visa Status Update',
description: 'Email to the attendee when their visa application status changes',
sendEmail: true,
},
{
notificationType: 'GRAND_FINAL_DOCS_REMINDER',
category: 'logistics',
label: 'Final Documents Reminder',
description: 'Reminder to finalist teams to upload their Grand Final documents before the deadline',
sendEmail: true,
},
{
notificationType: 'GRAND_FINAL_DOCS_SUBMITTED',
category: 'logistics',
label: 'Final Documents Submitted',
description: 'Notifies the team mentor when a finalist uploads a Grand Final document',
sendEmail: false,
},
// Admin notifications (in-app only by default)
{
notificationType: 'FILTERING_COMPLETE',

View File

@@ -0,0 +1,33 @@
// scripts/configure-grand-final-requirements.mjs
// Usage: node scripts/configure-grand-final-requirements.mjs (dry-run, prints plan)
// node scripts/configure-grand-final-requirements.mjs --apply (writes)
import { PrismaClient } from '@prisma/client'
const p = new PrismaClient()
const APPLY = process.argv.includes('--apply')
const TARGET = [
{ name: 'Final Presentation', acceptedMimeTypes: ['application/pdf'], sortOrder: 1, renameFrom: 'PDF presentation support' },
{ name: 'Final Business Plan', acceptedMimeTypes: ['application/pdf'], sortOrder: 2 },
{ name: '1-minute Video', acceptedMimeTypes: ['video/*'], sortOrder: 3, renameFrom: '1 minute video' },
{ name: 'Executive Summary', acceptedMimeTypes: ['application/pdf'], sortOrder: 4 },
]
const run = async () => {
const round = await p.round.findFirst({ where: { roundType: 'LIVE_FINAL' }, orderBy: { sortOrder: 'desc' } })
if (!round) throw new Error('No LIVE_FINAL round')
const existing = await p.fileRequirement.findMany({ where: { roundId: round.id } })
console.log(`Round "${round.name}" (${round.id}); existing reqs: ${existing.map((r) => r.name).join(', ') || 'none'}`)
for (const t of TARGET) {
const match = existing.find((r) => r.name === t.name || (t.renameFrom && r.name === t.renameFrom))
if (match) {
console.log(`UPDATE "${match.name}" -> name="${t.name}" required=true sort=${t.sortOrder} mimes=${t.acceptedMimeTypes}`)
if (APPLY) await p.fileRequirement.update({ where: { id: match.id }, data: { name: t.name, acceptedMimeTypes: t.acceptedMimeTypes, isRequired: true, sortOrder: t.sortOrder } })
} else {
console.log(`CREATE "${t.name}" required=true sort=${t.sortOrder} mimes=${t.acceptedMimeTypes}`)
if (APPLY) await p.fileRequirement.create({ data: { roundId: round.id, name: t.name, acceptedMimeTypes: t.acceptedMimeTypes, isRequired: true, sortOrder: t.sortOrder } })
}
}
console.log(APPLY ? 'APPLIED.' : 'DRY-RUN (pass --apply to write).')
}
run().catch((e) => { console.error(e); process.exit(1) }).finally(() => p.$disconnect())

View File

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

View File

@@ -16,6 +16,7 @@ import { TravelTab } from '@/components/admin/logistics/travel-tab'
import { HotelsTab } from '@/components/admin/logistics/hotels-tab'
import { VisasTab } from '@/components/admin/logistics/visas-tab'
import { LunchTab } from '@/components/admin/logistics/lunch-tab'
import { EmailTemplatesTab } from '@/components/admin/logistics/email-templates-tab'
export default function LogisticsPage() {
const { currentEdition } = useEdition()
@@ -56,9 +57,8 @@ export default function LogisticsPage() {
<TabsTrigger value="lunch">
<Salad className="mr-2 h-4 w-4" /> Lunch
</TabsTrigger>
<TabsTrigger value="email-templates" disabled>
<TabsTrigger value="email-templates">
<ScrollText className="mr-2 h-4 w-4" /> Email Templates
<span className="text-muted-foreground ml-1 text-xs">(soon)</span>
</TabsTrigger>
</TabsList>
@@ -77,6 +77,9 @@ export default function LogisticsPage() {
<TabsContent value="lunch">
<LunchTab programId={programId} />
</TabsContent>
<TabsContent value="email-templates">
<EmailTemplatesTab programId={programId} />
</TabsContent>
</Tabs>
</div>
)

View File

@@ -79,6 +79,8 @@ import {
ListChecks,
FileText,
Languages,
MonitorPlay,
Scale,
} from 'lucide-react'
import {
Tooltip,
@@ -93,8 +95,14 @@ import { FileRequirementsEditor } from '@/components/admin/round/file-requiremen
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
import { MentoringRoundOverview } from '@/components/admin/round/mentoring-round-overview'
import { MentoringProjectsTable } from '@/components/admin/round/mentoring-projects-table'
import { LiveControlPanel } from '@/components/admin/live/live-control-panel'
import { DeliberationControlPanel } from '@/components/admin/deliberation/deliberation-control-panel'
import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card'
import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card'
import { FinalistEnrollmentCard } from '@/components/admin/grand-finale/finalist-enrollment-card'
import { FinalDocsReminderButton } from '@/components/admin/grand-finale/final-docs-reminder-button'
import { FinalDocsUploadsToggle } from '@/components/admin/grand-finale/final-docs-uploads-toggle'
import { ReviewDocsPicker } from '@/components/admin/grand-finale/review-docs-picker'
import { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
@@ -969,6 +977,10 @@ export default function RoundDetailPage() {
...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []),
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments & Jury', icon: ClipboardList }] : []),
...(isEvaluation ? [{ value: 'ranking', label: 'Ranking', icon: BarChart3 }] : []),
...(isGrandFinale ? [{ value: 'ceremony', label: 'Ceremony', icon: MonitorPlay }] : []),
...(round?.roundType === 'DELIBERATION'
? [{ value: 'deliberation', label: 'Deliberation', icon: Scale }]
: []),
...(hasJury && !isEvaluation ? [{ value: 'jury', label: 'Jury', icon: Users }] : []),
...(showFinalization ? [{ value: 'finalization', label: 'Finalization', icon: ListChecks }] : []),
{ value: 'config', label: 'Config', icon: Settings },
@@ -1526,10 +1538,26 @@ export default function RoundDetailPage() {
{/* Grand-finale logistics \u2014 only on LIVE_FINAL rounds */}
{isGrandFinale && programId && (
<div className="grid gap-4 md:grid-cols-2">
<FinalistSlotsCard programId={programId} />
<WaitlistCard programId={programId} />
</div>
<>
<FinalistEnrollmentCard programId={programId} roundId={roundId} />
<div className="flex items-center justify-between gap-2 flex-wrap">
<FinalDocsUploadsToggle roundId={roundId} />
<div className="flex 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>
<ReviewDocsPicker programId={programId} roundId={roundId} />
<div className="grid gap-4 md:grid-cols-2">
<FinalistSlotsCard programId={programId} />
<WaitlistCard programId={programId} />
</div>
</>
)}
{/* Round Info + Project Breakdown */}
@@ -1642,6 +1670,20 @@ export default function RoundDetailPage() {
</TabsContent>
)}
{/* ═══════════ CEREMONY TAB (LIVE_FINAL) ═══════════ */}
{isGrandFinale && (
<TabsContent value="ceremony" className="space-y-4">
<LiveControlPanel roundId={roundId} competitionId={competitionId} />
</TabsContent>
)}
{/* ═══════════ DELIBERATION TAB (DELIBERATION rounds) ═══════════ */}
{round?.roundType === 'DELIBERATION' && (
<TabsContent value="deliberation" className="space-y-4">
<DeliberationControlPanel roundId={roundId} competitionId={competitionId} />
</TabsContent>
)}
{/* ═══════════ JURY TAB (non-EVALUATION jury rounds: LIVE_FINAL, DELIBERATION) ═══════════ */}
{hasJury && !isEvaluation && (
<TabsContent value="jury" className="space-y-6">

View File

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

View File

@@ -19,6 +19,8 @@ import { CompetitionTimelineSidebar } from '@/components/applicant/competition-t
import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
import { MentorConversationCard } from '@/components/applicant/mentor-conversation-card'
import { AttendingMembersCard } from '@/components/applicant/attending-members-card'
import { MyLogisticsCard } from '@/components/applicant/my-logistics-card'
import { FinalDocumentsBanner } from '@/components/applicant/final-documents-banner'
import { LunchBanner } from '@/components/applicant/lunch-banner'
import { ExternalAttendeesStrip } from '@/components/applicant/external-attendees-strip'
import { AnimatedCard } from '@/components/shared/animated-container'
@@ -205,6 +207,9 @@ export default function ApplicantDashboardPage() {
</div>
</div>
{/* Grand Final document upload banner (auto-hides for non-finalists) */}
<FinalDocumentsBanner />
{/* Active round deadline banner */}
{!isRejected && openRounds.length > 0 && (() => {
const submissionTypes = new Set(['INTAKE', 'SUBMISSION', 'MENTORING'])
@@ -414,6 +419,9 @@ export default function ApplicantDashboardPage() {
{/* Grand finale attendee roster (auto-hides until confirmation status is CONFIRMED) */}
<AttendingMembersCard />
{/* Grand-finale logistics: hotel, flight, visa (auto-hides when not a confirmed finalist) */}
<MyLogisticsCard />
{/* Conversation with assigned mentor (auto-hides when no mentor assigned) */}
<MentorConversationCard projectId={project.id} />

View File

@@ -1,76 +1,142 @@
'use client'
import { use, useState } from 'react'
import { use, useEffect, useMemo, useRef, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { LiveVotingForm } from '@/components/jury/live-voting-form'
import { remainingSeconds, formatClock } from '@/lib/live-timer'
import { Clock, Mic2, MessageCircleQuestion, PenLine, Sparkles } from 'lucide-react'
import { toast } from 'sonner'
const PHASE_META: Record<string, { label: string; icon: typeof Mic2 }> = {
PRESENTING: { label: 'Presentation', icon: Mic2 },
QA: { label: 'Q&A', icon: MessageCircleQuestion },
SCORING: { label: 'Scoring open', icon: PenLine },
}
function PhaseCountdown({ phase }: { phase: {
phaseStartedAt: Date | string | null
phaseDurationSeconds: number | null
phasePausedAt: Date | string | null
phasePausedAccumMs: number
} }) {
const [, tick] = useState(0)
useEffect(() => {
const id = setInterval(() => tick((t) => t + 1), 1000)
return () => clearInterval(id)
}, [])
const remaining = remainingSeconds(phase)
if (remaining === null) return null
const over = remaining < 0
return (
<Badge
variant={over ? 'destructive' : 'secondary'}
className={`gap-1 tabular-nums text-sm ${over ? 'animate-pulse' : ''}`}
>
<Clock className="h-3.5 w-3.5" />
{formatClock(remaining)}
{over && <span className="font-semibold">OVER</span>}
{phase.phasePausedAt && <span>· paused</span>}
</Badge>
)
}
export default function JuryLivePage({ params: paramsPromise }: { params: Promise<{ roundId: string }> }) {
const params = use(paramsPromise)
const utils = trpc.useUtils()
const [notes, setNotes] = useState('')
const [priorDataOpen, setPriorDataOpen] = useState(false)
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId: params.roundId })
// Fetch live voting session data
const { data: sessionData } = trpc.liveVoting.getSessionForVoting.useQuery(
{ sessionId: params.roundId },
{ enabled: !!params.roundId, refetchInterval: 2000 }
const { data: cursor } = trpc.live.getCursor.useQuery(
{ roundId: params.roundId },
{ refetchInterval: 2000 }
)
const { data: sessionData } = trpc.liveVoting.getSessionForVotingByRound.useQuery(
{ roundId: params.roundId },
{ refetchInterval: 2000 }
)
const { data: myNotes } = trpc.live.getMyNotes.useQuery({ roundId: params.roundId })
// Placeholder for prior data - this would need to be implemented in evaluation router
const priorData = null as { averageScore?: number; evaluationCount?: number; strengths?: string; weaknesses?: string } | null
const submitVoteMutation = trpc.liveVoting.vote.useMutation({
onSuccess: () => {
utils.liveVoting.getSessionForVoting.invalidate()
toast.success('Vote submitted successfully')
},
onError: (err: any) => {
toast.error(err.message)
},
// ── Persisted notes (autosave, keyed per project) ────────────────────────
const [noteDrafts, setNoteDrafts] = useState<Record<string, string>>({})
const [noteStatus, setNoteStatus] = useState<'idle' | 'saving' | 'saved'>('idle')
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const saveNote = trpc.live.saveNote.useMutation({
onSuccess: () => setNoteStatus('saved'),
onError: () => setNoteStatus('idle'),
})
const handleVoteSubmit = (vote: { score: number; criterionScores?: Record<string, number> }) => {
const projectId = cursor?.activeProject?.id || sessionData?.currentProject?.id
if (!projectId) return
const activeProject = cursor?.activeProject ?? null
const activeProjectId = activeProject?.id ?? null
const sessionId = sessionData?.session?.id || params.roundId
const savedNoteFor = useMemo(() => {
const map: Record<string, string> = {}
for (const n of myNotes ?? []) map[n.projectId] = n.content
return map
}, [myNotes])
const currentDraft =
activeProjectId != null
? noteDrafts[activeProjectId] ?? savedNoteFor[activeProjectId] ?? ''
: ''
const handleNoteChange = (value: string) => {
if (!activeProjectId) return
setNoteDrafts((d) => ({ ...d, [activeProjectId]: value }))
setNoteStatus('saving')
if (saveTimer.current) clearTimeout(saveTimer.current)
const projectId = activeProjectId
saveTimer.current = setTimeout(() => {
saveNote.mutate({ roundId: params.roundId, projectId, content: value })
}, 800)
}
// ── Voting ───────────────────────────────────────────────────────────────
const submitVoteMutation = trpc.liveVoting.vote.useMutation({
onSuccess: () => {
utils.liveVoting.getSessionForVotingByRound.invalidate()
toast.success('Vote submitted')
},
onError: (err) => toast.error(err.message),
})
const handleVoteSubmit = (vote: {
score: number
criterionScores?: Record<string, number>
comment?: string
}) => {
if (!activeProjectId || !sessionData?.session?.id) return
submitVoteMutation.mutate({
sessionId,
projectId,
sessionId: sessionData.session.id,
projectId: activeProjectId,
score: vote.score,
criterionScores: vote.criterionScores,
comment: vote.comment,
})
}
// Extract voting mode and criteria from session
const votingMode = (sessionData?.session?.votingMode ?? 'simple') as 'simple' | 'criteria'
const criteria = (sessionData?.session?.criteriaJson as Array<{
id: string
label: string
description?: string
scale: number
weight: number
}> | undefined)
const criteria = sessionData?.session?.criteriaJson as
| Array<{ id: string; label: string; description?: string; scale: number; weight: number }>
| undefined
const activeProject = cursor?.activeProject || sessionData?.currentProject
const phase = cursor?.projectPhase ?? 'ON_DECK'
const categoryLabel =
activeProject?.competitionCategory === 'STARTUP'
? 'Startup'
: activeProject?.competitionCategory === 'BUSINESS_CONCEPT'
? 'Business Concept'
: null
if (!activeProject) {
return (
<div className="space-y-6">
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground">Waiting for ceremony to begin...</p>
<Sparkles className="mb-3 h-8 w-8 text-brand-teal/60" />
<p className="font-medium">Waiting for the ceremony to begin</p>
<p className="mt-2 text-sm text-muted-foreground">
The admin will control which project is displayed
Projects will appear here automatically as they take the stage
</p>
</CardContent>
</Card>
@@ -78,105 +144,116 @@ export default function JuryLivePage({ params: paramsPromise }: { params: Promis
)
}
// ── ON_DECK: "Up next" banner, no scoring yet ───────────────────────────
if (phase === 'ON_DECK') {
return (
<div className="space-y-6">
<Card className="overflow-hidden border-0 bg-gradient-to-r from-[#053d57] to-[#0a5a7c] text-white">
<CardContent className="py-12 text-center">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-white/70">
Up next
</p>
<h1 className="mt-3 text-3xl font-bold sm:text-4xl">{activeProject.title}</h1>
{activeProject.teamName && (
<p className="mt-2 text-lg text-white/80">{activeProject.teamName}</p>
)}
{categoryLabel && (
<Badge className="mt-4 bg-white/15 text-white hover:bg-white/15">{categoryLabel}</Badge>
)}
<p className="mt-6 text-sm text-white/60">Presentation starting shortly</p>
</CardContent>
</Card>
{activeProject.description && (
<Card>
<CardHeader>
<CardTitle className="text-base">About this project</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{activeProject.description}</p>
</CardContent>
</Card>
)}
</div>
)
}
const phaseMeta = PHASE_META[phase] ?? PHASE_META.PRESENTING
const PhaseIcon = phaseMeta.icon
return (
<div className="space-y-6">
{/* Current Project Display */}
{/* Current Project + phase */}
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle className="text-2xl">{activeProject.title}</CardTitle>
<CardDescription className="mt-2">
Live project presentation
<CardDescription className="mt-1">
{activeProject.teamName}
{categoryLabel ? ` · ${categoryLabel}` : ''}
</CardDescription>
</div>
{votingMode === 'criteria' && (
<Badge variant="secondary">Criteria Voting</Badge>
)}
<div className="flex items-center gap-2">
<Badge variant={phase === 'SCORING' ? 'default' : 'outline'} className="gap-1.5">
<PhaseIcon className="h-3.5 w-3.5" />
{phaseMeta.label}
</Badge>
{cursor && <PhaseCountdown phase={cursor} />}
</div>
</div>
</CardHeader>
{activeProject.description && (
<CardContent>
<p className="text-sm text-muted-foreground">{activeProject.description}</p>
</CardContent>
)}
</Card>
{/* Notes — persisted, autosaved */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Your Notes</CardTitle>
<CardDescription>Private resurfaced during deliberation</CardDescription>
</div>
<span className="text-xs text-muted-foreground">
{noteStatus === 'saving' ? 'Saving…' : noteStatus === 'saved' ? 'Saved' : ''}
</span>
</div>
</CardHeader>
<CardContent>
{activeProject.description && (
<p className="text-muted-foreground">{activeProject.description}</p>
)}
</CardContent>
</Card>
{/* Prior Jury Data (Collapsible) */}
{priorData && (
<Collapsible open={priorDataOpen} onOpenChange={setPriorDataOpen}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Prior Evaluation Data</CardTitle>
{priorDataOpen ? (
<ChevronUp className="h-5 w-5" />
) : (
<ChevronDown className="h-5 w-5" />
)}
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<p className="text-sm font-medium text-muted-foreground">Average Score</p>
<p className="mt-1 text-2xl font-bold">
{priorData.averageScore?.toFixed(1) || 'N/A'}
</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
<p className="mt-1 text-2xl font-bold">{priorData.evaluationCount || 0}</p>
</div>
</div>
{priorData.strengths && (
<div>
<p className="text-sm font-medium text-muted-foreground">Key Strengths</p>
<p className="mt-1 text-sm">{priorData.strengths}</p>
</div>
)}
{priorData.weaknesses && (
<div>
<p className="text-sm font-medium text-muted-foreground">Areas for Improvement</p>
<p className="mt-1 text-sm">{priorData.weaknesses}</p>
</div>
)}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
)}
{/* Notes Section */}
<Card>
<CardHeader>
<CardTitle>Your Notes</CardTitle>
<CardDescription>Optional notes for this project</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add your observations and comments..."
value={currentDraft}
onChange={(e) => handleNoteChange(e.target.value)}
placeholder="Observations during the presentation and Q&A…"
rows={4}
/>
</CardContent>
</Card>
{/* Voting Form */}
{/* Scoring — available from presentation start, spotlighted at SCORING.
Keyed on vote presence: the form initializes its editing state from
existingVote, which arrives async after mount. */}
<LiveVotingForm
key={`${activeProject.id}-${sessionData?.userVote?.votedAt ?? 'fresh'}`}
projectId={activeProject.id}
votingMode={votingMode}
criteria={criteria}
existingVote={sessionData?.userVote ? {
score: sessionData.userVote.score,
criterionScoresJson: sessionData.userVote.criterionScoresJson as Record<string, number> | undefined
} : null}
existingVote={
sessionData?.userVote
? {
score: sessionData.userVote.score,
criterionScoresJson: sessionData.userVote.criterionScoresJson as
| Record<string, number>
| undefined,
comment: sessionData.userVote.comment,
}
: null
}
onVoteSubmit={handleVoteSubmit}
disabled={submitVoteMutation.isPending}
highlighted={phase === 'SCORING'}
/>
</div>
)

View File

@@ -1,150 +1,326 @@
'use client';
'use client'
import { use } from 'react';
import { trpc } from '@/lib/trpc/client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { DeliberationRankingForm } from '@/components/jury/deliberation-ranking-form';
import { CheckCircle2 } from 'lucide-react';
import { toast } from 'sonner';
import { use, useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { DeliberationRankingForm } from '@/components/jury/deliberation-ranking-form'
import { LiveVotingForm } from '@/components/jury/live-voting-form'
import { CheckCircle2, ChevronDown, FileText, PenLine, StickyNote } from 'lucide-react'
import { toast } from 'sonner'
export default function JuryDeliberationPage({ params: paramsPromise }: { params: Promise<{ sessionId: string }> }) {
const params = use(paramsPromise);
const utils = trpc.useUtils();
const CATEGORY_LABEL: Record<string, string> = {
BUSINESS_CONCEPT: 'Business Concepts',
STARTUP: 'Startups',
}
/**
* Per-project review context during deliberation: the juror's finale scores
* (revisable in place — "keep" is simply not touching them), their ceremony
* notes, and a pointer to the project documents.
*/
function ProjectReviewCard({
project,
roundId,
finaleInputs,
votingMode,
criteria,
}: {
project: { id: string; title: string; teamName?: string | null }
roundId: string
finaleInputs: any
votingMode: 'simple' | 'criteria'
criteria?: Array<{ id: string; label: string; description?: string; scale: number; weight: number }>
}) {
const utils = trpc.useUtils()
const [open, setOpen] = useState(false)
const myVote = finaleInputs?.votes?.find((v: any) => v.projectId === project.id)
const myNote = finaleInputs?.notes?.find((n: any) => n.projectId === project.id)
const voteMutation = trpc.liveVoting.vote.useMutation({
onSuccess: () => {
utils.liveVoting.getMyFinaleInputs.invalidate({ roundId })
toast.success('Score updated')
},
onError: (err) => toast.error(err.message),
})
return (
<Collapsible open={open} onOpenChange={setOpen}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer py-4 hover:bg-muted/40">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">{project.title}</CardTitle>
{project.teamName && (
<CardDescription className="mt-0.5">{project.teamName}</CardDescription>
)}
</div>
<div className="flex items-center gap-2">
{myVote ? (
<Badge variant="secondary" className="tabular-nums">
My score: {myVote.score}/10
</Badge>
) : (
<Badge variant="outline">Not scored</Badge>
)}
<ChevronDown className={`h-4 w-4 transition-transform ${open ? 'rotate-180' : ''}`} />
</div>
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4 border-t pt-4">
{myNote?.content && (
<div className="rounded-lg bg-muted/40 p-3">
<p className="mb-1 flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
<StickyNote className="h-3.5 w-3.5" />
Your ceremony notes
</p>
<p className="whitespace-pre-wrap text-sm">{myNote.content}</p>
</div>
)}
<div>
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
<PenLine className="h-3.5 w-3.5" />
Your grand-finale score edit to revise, or leave as-is to keep it
</p>
{finaleInputs?.session?.id ? (
<LiveVotingForm
key={`${project.id}-${myVote?.votedAt ?? 'fresh'}`}
projectId={project.id}
votingMode={votingMode}
criteria={criteria}
existingVote={
myVote
? {
score: myVote.score,
criterionScoresJson: myVote.criterionScoresJson as
| Record<string, number>
| undefined,
comment: myVote.comment,
}
: null
}
onVoteSubmit={(vote) =>
voteMutation.mutate({
sessionId: finaleInputs.session.id,
projectId: project.id,
score: vote.score,
criterionScores: vote.criterionScores,
comment: vote.comment,
})
}
disabled={voteMutation.isPending}
/>
) : (
<p className="text-sm text-muted-foreground">No finale voting session found.</p>
)}
</div>
<Button asChild variant="outline" size="sm">
<Link href="/jury/finals-documents">
<FileText className="mr-2 h-3.5 w-3.5" />
Open project documents
</Link>
</Button>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
)
}
export default function JuryDeliberationPage({
params: paramsPromise,
}: {
params: Promise<{ sessionId: string }>
}) {
const params = use(paramsPromise)
const utils = trpc.useUtils()
const { data: me } = trpc.user.me.useQuery()
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery(
{ sessionId: params.sessionId },
{ refetchInterval: 10_000 },
);
{ refetchInterval: 10_000 }
)
// The deliberation session points at its round; finale inputs live on the
// LIVE_FINAL round's voting session — resolve via my ceremony context.
const { data: ceremony } = trpc.live.getMyCeremonyContext.useQuery()
const finaleRoundId = ceremony?.liveRoundId ?? null
const { data: finaleInputs } = trpc.liveVoting.getMyFinaleInputs.useQuery(
{ roundId: finaleRoundId ?? '' },
{ enabled: !!finaleRoundId }
)
const submitVoteMutation = trpc.deliberation.submitVote.useMutation({
onSuccess: () => {
utils.deliberation.getSession.invalidate();
toast.success('Vote submitted successfully');
},
onError: (err) => {
toast.error(err.message);
const [submitting, setSubmitting] = useState(false)
const submitVoteMutation = trpc.deliberation.submitVote.useMutation()
const handleSubmitVote = async (
votes: Array<{ projectId: string; rank?: number; isWinnerPick?: boolean }>
) => {
setSubmitting(true)
try {
for (const vote of votes) {
await submitVoteMutation.mutateAsync({
sessionId: params.sessionId,
projectId: vote.projectId,
rank: vote.rank,
isWinnerPick: vote.isWinnerPick,
})
}
toast.success('Your ranking has been submitted')
utils.deliberation.getSession.invalidate({ sessionId: params.sessionId })
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to submit vote')
} finally {
setSubmitting(false)
}
});
}
const handleSubmitVote = (votes: Array<{ projectId: string; rank?: number; isWinnerPick?: boolean }>) => {
votes.forEach((vote) => {
submitVoteMutation.mutate({
sessionId: params.sessionId,
juryMemberId: '', // TODO: resolve current user's jury member ID from session participants
projectId: vote.projectId,
rank: vote.rank,
isWinnerPick: vote.isWinnerPick
});
});
};
if (isLoading) {
if (isLoading || !me) {
return (
<div className="space-y-6">
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">Loading session...</p>
</CardContent>
</Card>
</div>
);
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">Loading session</p>
</CardContent>
</Card>
)
}
if (!session) {
return (
<div className="space-y-6">
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">Session not found</p>
</CardContent>
</Card>
</div>
);
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">Session not found</p>
</CardContent>
</Card>
)
}
const hasVoted = false; // TODO: check if current user has voted in this session
const isParticipant = (session.participants ?? []).some(
(p: any) => p.user?.user?.id === me.id
)
const hasVoted = (session.votes ?? []).some(
(v: any) => v.juryMember?.user?.id === me.id && v.runoffRound === 0
)
const projects = ((session as any).projects ?? []) as Array<{
id: string
title: string
teamName?: string | null
}>
const votingMode = (finaleInputs?.session?.votingMode ?? 'simple') as 'simple' | 'criteria'
const criteria = finaleInputs?.session?.criteriaJson as
| Array<{ id: string; label: string; description?: string; scale: number; weight: number }>
| undefined
if (session.status !== 'VOTING') {
const header = (
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Deliberation {CATEGORY_LABEL[session.category] ?? session.category}</CardTitle>
<CardDescription className="mt-1">{session.round?.name}</CardDescription>
</div>
<Badge>{session.status}</Badge>
</div>
</CardHeader>
</Card>
)
const reviewSection = projects.length > 0 && finaleRoundId && (
<div className="space-y-3">
<div>
<h2 className="text-lg font-semibold">Review Before You Rank</h2>
<p className="text-sm text-muted-foreground">
Your grand-finale scores, notes and the project documents revise a score or keep it.
</p>
</div>
{projects.map((p) => (
<ProjectReviewCard
key={p.id}
project={p}
roundId={finaleRoundId}
finaleInputs={finaleInputs}
votingMode={votingMode}
criteria={criteria}
/>
))}
</div>
)
if (session.status !== 'VOTING' && session.status !== 'RUNOFF') {
return (
<div className="space-y-6">
{header}
<Card>
<CardHeader>
<CardTitle>Deliberation Session</CardTitle>
<CardDescription>
{session.round?.name} - {session.category}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center justify-center py-12">
<CardContent className="flex flex-col items-center justify-center py-10">
<p className="text-muted-foreground">
{session.status === 'DELIB_OPEN'
? 'Voting has not started yet. Please wait for the admin to open voting.'
? 'Voting has not started yet — you can already review the projects below.'
: session.status === 'TALLYING'
? 'Voting is closed. Results are being tallied.'
: 'This session is locked.'}
? 'Voting is closed. Results are being tallied.'
: 'This session is locked.'}
</p>
</CardContent>
</Card>
{session.status === 'DELIB_OPEN' && reviewSection}
</div>
);
)
}
if (hasVoted) {
if (!isParticipant) {
return (
<div className="space-y-6">
{header}
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Deliberation Session</CardTitle>
<CardDescription className="mt-1">
{session.round?.name} - {session.category}
</CardDescription>
</div>
<Badge>{session.status}</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-col items-center justify-center py-12">
<CheckCircle2 className="mb-4 h-12 w-12 text-green-600" />
<p className="font-medium">Vote Submitted</p>
<p className="mt-1 text-sm text-muted-foreground">
Thank you for your participation in this deliberation
<CardContent className="flex flex-col items-center justify-center py-10">
<p className="text-muted-foreground">
You are not a participant of this deliberation session.
</p>
</CardContent>
</Card>
</div>
);
)
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Deliberation Session</CardTitle>
<CardDescription className="mt-1">
{session.round?.name} - {session.category}
</CardDescription>
</div>
<Badge>{session.status}</Badge>
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
{session.mode === 'SINGLE_WINNER_VOTE'
? 'Select your top choice for this category.'
: 'Rank all projects from best to least preferred.'}
</p>
</CardContent>
</Card>
{header}
<DeliberationRankingForm
projects={session.results?.map((r) => r.project) ?? []}
mode={session.mode}
onSubmit={handleSubmitVote}
disabled={submitVoteMutation.isPending}
/>
{hasVoted ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-10">
<CheckCircle2 className="mb-3 h-12 w-12 text-green-600" />
<p className="font-medium">Ranking Submitted</p>
<p className="mt-1 text-sm text-muted-foreground">
Thank you the chair will review the collective result.
</p>
</CardContent>
</Card>
) : (
<>
{reviewSection}
<div>
<h2 className="mb-2 text-lg font-semibold">
{session.mode === 'SINGLE_WINNER_VOTE' ? 'Pick Your Winner' : 'Your Ranking'}
</h2>
<DeliberationRankingForm
projects={projects}
mode={session.mode}
onSubmit={handleSubmitVote}
disabled={submitting}
/>
</div>
</>
)}
</div>
);
)
}

View File

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

View File

@@ -28,7 +28,9 @@ import {
Waves,
Send,
Trophy,
FileText,
} from 'lucide-react'
import { userCanReviewFinals, getOpenFinaleRound } from '@/server/services/final-documents'
import { formatDateOnly } from '@/lib/utils'
import { CountdownTimer } from '@/components/shared/countdown-timer'
import { AnimatedCard } from '@/components/shared/animated-container'
@@ -42,6 +44,70 @@ function getGreeting(): string {
return 'Good evening'
}
/**
* Prominent entry point to the finalist documents review, shown only to
* Grand-Final jury members (and admins). Rendered at the top of the dashboard
* regardless of whether the juror has individual assignments, so finals jurors
* can always find the teams' files in one obvious place.
*/
async function FinalsJuryBanner() {
const session = await auth()
const userId = session?.user?.id
if (!userId) return null
const program = await prisma.program.findFirst({
where: { status: 'ACTIVE' },
orderBy: { year: 'desc' },
select: { id: true },
})
if (!program) return null
const canReview = await userCanReviewFinals(prisma, userId, session.user.role, program.id)
if (!canReview) return null
const round = await getOpenFinaleRound(prisma, program.id)
const teamCount = round
? await prisma.projectRoundState.count({ where: { roundId: round.id } })
: 0
return (
<AnimatedCard index={0}>
<Card className="overflow-hidden border-0 shadow-lg">
<div className="rounded-lg bg-gradient-to-r from-brand-blue to-brand-teal p-[1px]">
<CardContent className="flex flex-col gap-4 rounded-[7px] bg-background p-5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-4">
<div className="shrink-0 rounded-xl bg-gradient-to-br from-brand-blue to-brand-teal p-3 shadow-sm">
<Trophy className="h-6 w-6 text-white" />
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-brand-teal">
Grand Final
</p>
<h2 className="text-lg font-bold text-brand-blue">Finalist Documents</h2>
<p className="mt-0.5 max-w-md text-sm text-muted-foreground">
{teamCount > 0 ? `All ${teamCount} finalist teams ` : 'Every finalist teams '}
pitch decks, business plans, executive summaries and videos in one place.
</p>
</div>
</div>
<Button
asChild
size="lg"
className="w-full shrink-0 bg-brand-blue shadow-md hover:bg-brand-blue-light sm:w-auto"
>
<Link href="/jury/finals-documents">
<FileText className="mr-2 h-4 w-4" />
Review Finalist Documents
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</CardContent>
</div>
</Card>
</AnimatedCard>
)
}
async function JuryDashboardContent() {
const session = await auth()
const userId = session?.user?.id
@@ -863,6 +929,11 @@ export default async function JuryDashboardPage() {
{/* Preferences banner (shown when juror has unconfirmed preferences) */}
<JuryPreferencesBanner />
{/* Grand-Final finalist documents — prominent entry for finals jurors */}
<Suspense fallback={null}>
<FinalsJuryBanner />
</Suspense>
{/* Content */}
<Suspense fallback={<DashboardSkeleton />}>
<JuryDashboardContent />

View File

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

View File

@@ -1,6 +1,7 @@
'use client'
import { Suspense, use, useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -180,8 +181,11 @@ function FinalistConfirmContent({ token }: { token: string }) {
</p>
<p className="text-muted-foreground text-sm">
We&apos;ll be in touch shortly with travel and lunch logistics. You can edit your team
selection from your project page closer to the event.
selection and view hotel, flight, and visa details from your dashboard.
</p>
<Button asChild className="mt-4">
<Link href="/applicant">Go to my dashboard</Link>
</Button>
</CardContent>
</Card>
)

View File

@@ -0,0 +1,535 @@
'use client'
/**
* Big-screen ceremony view — projected on stage at the grand finale.
* Award-night broadcast aesthetic: deep layered ocean field, extreme
* Montserrat scale contrast, red as a scalpel accent, gold reserved for the
* winner moment. Pure derivation of server state (poll 2s), full-bleed over
* the public layout, no interactive chrome.
*/
import { use, useEffect, useMemo, useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { QRCodeSVG } from 'qrcode.react'
import { trpc } from '@/lib/trpc/client'
import { remainingSeconds, formatClock } from '@/lib/live-timer'
const CATEGORY_LABEL: Record<string, string> = {
BUSINESS_CONCEPT: 'Business Concepts',
STARTUP: 'Startups',
}
const WINDOW_TITLE: Record<string, string> = {
'CATEGORY:BUSINESS_CONCEPT': 'Vote for your favorite Business Concept',
'CATEGORY:STARTUP': 'Vote for your favorite Startup',
OVERALL: 'Vote for your favorite project of the night',
}
function useTick() {
const [, tick] = useState(0)
useEffect(() => {
const id = setInterval(() => tick((t) => t + 1), 1000)
return () => clearInterval(id)
}, [])
}
// ─── Atmosphere ──────────────────────────────────────────────────────────────
function OceanField({ children }: { children: React.ReactNode }) {
return (
<div className="fixed inset-0 z-50 overflow-hidden bg-[#021f2e] font-[Montserrat,sans-serif] text-white">
{/* Layered ocean-light gradients */}
<div
className="absolute inset-0"
style={{
background:
'radial-gradient(120% 90% at 50% 110%, #0a5a7c 0%, #053d57 45%, #021f2e 100%)',
}}
/>
<motion.div
className="absolute -inset-x-1/4 top-[-40%] h-[80%] opacity-25"
style={{
background:
'radial-gradient(50% 100% at 50% 0%, rgba(85,127,140,0.9) 0%, transparent 70%)',
}}
animate={{ x: ['-8%', '8%', '-8%'] }}
transition={{ repeat: Infinity, duration: 18, ease: 'easeInOut' }}
/>
{/* Grain for projector richness */}
<div
className="pointer-events-none absolute inset-0 opacity-[0.05] mix-blend-overlay"
style={{
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")",
}}
/>
{children}
</div>
)
}
function StatusBar({ programName, label }: { programName: string | null; label?: string | null }) {
return (
<div className="absolute inset-x-0 top-0 flex items-center justify-between px-12 py-8">
<p className="text-sm font-semibold uppercase tracking-[0.4em] text-white/50">
{programName ?? 'Monaco Ocean Protection Challenge'}
</p>
{label && (
<p className="flex items-center gap-3 text-sm font-semibold uppercase tracking-[0.4em] text-white/50">
<span className="inline-block h-2.5 w-2.5 animate-pulse rounded-full bg-[#de0f1e]" />
{label}
</p>
)}
</div>
)
}
function SignatureRule() {
return (
<div className="mx-auto flex w-48 items-center gap-0">
<div className="h-px flex-1 bg-white/25" />
<div className="h-[3px] w-10 bg-[#de0f1e]" />
<div className="h-px flex-1 bg-white/25" />
</div>
)
}
const slideIn = {
initial: { opacity: 0, y: 36, scale: 0.985, filter: 'blur(6px)' },
animate: { opacity: 1, y: 0, scale: 1, filter: 'blur(0px)' },
exit: { opacity: 0, y: -24, scale: 0.99, filter: 'blur(4px)' },
transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] as const },
}
// ─── Slides ──────────────────────────────────────────────────────────────────
function StaticSlide({ kind, programName }: { kind: string; programName: string | null }) {
const copy: Record<string, { eyebrow: string; title: string; sub?: string }> = {
welcome: {
eyebrow: programName ?? 'Monaco Ocean Protection Challenge',
title: 'Grand Finale',
sub: 'Welcome',
},
break: { eyebrow: 'Intermission', title: 'Back shortly', sub: 'Enjoy the break' },
deliberation: {
eyebrow: 'The jury has retired',
title: 'Deliberation in progress',
sub: 'Results follow shortly',
},
thanks: { eyebrow: programName ?? 'Grand Finale', title: 'Thank you', sub: 'See you next year' },
}
const c = copy[kind] ?? copy.welcome
return (
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
<p className="text-lg font-semibold uppercase tracking-[0.5em] text-white/50">{c.eyebrow}</p>
<h1 className="text-[clamp(4rem,10vw,9rem)] font-extrabold leading-none tracking-tight">
{c.title}
</h1>
<SignatureRule />
{c.sub && <p className="text-2xl font-light text-white/70">{c.sub}</p>}
</div>
)
}
function PhaseSlide({
state,
}: {
state: {
phase: {
projectPhase: string
phaseStartedAt: string | Date | null
phaseDurationSeconds: number | null
phasePausedAt: string | Date | null
phasePausedAccumMs: number
} | null
activeProject: { title: string; teamName: string | null; competitionCategory: string | null } | null
}
}) {
useTick()
const phase = state.phase
const project = state.activeProject
if (!phase || !project) return <StaticSlide kind="welcome" programName={null} />
const remaining = remainingSeconds(phase)
const over = remaining !== null && remaining < 0
const category = project.competitionCategory
? CATEGORY_LABEL[project.competitionCategory]
: null
if (phase.projectPhase === 'ON_DECK') {
return (
<div className="flex h-full flex-col items-center justify-center gap-10 px-16 text-center">
<motion.p
initial={{ opacity: 0, y: -16 }}
animate={{ opacity: 1, y: 0 }}
className="text-xl font-semibold uppercase tracking-[0.5em] text-[#557f8c]"
>
Up next
</motion.p>
<motion.h1
initial={{ opacity: 0, y: 30, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: 'spring', stiffness: 80, damping: 16, delay: 0.15 }}
className="max-w-[90vw] text-[clamp(3.5rem,9vw,8rem)] font-extrabold leading-[1.02] tracking-tight"
>
{project.teamName ?? project.title}
</motion.h1>
<SignatureRule />
<div className="space-y-2">
{project.teamName && <p className="text-3xl font-light text-white/80">{project.title}</p>}
{category && (
<p className="text-lg font-semibold uppercase tracking-[0.35em] text-white/45">
{category}
</p>
)}
</div>
</div>
)
}
if (phase.projectPhase === 'SCORING') {
return (
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
<p className="text-lg font-semibold uppercase tracking-[0.5em] text-white/50">
{project.teamName ?? project.title}
</p>
<h1 className="text-[clamp(3rem,7vw,6rem)] font-extrabold tracking-tight">
The jury is scoring
</h1>
<SignatureRule />
<motion.div
className="flex gap-3"
initial="hidden"
animate="visible"
variants={{ visible: { transition: { staggerChildren: 0.25 } } }}
>
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className="h-3 w-3 rounded-full bg-[#557f8c]"
animate={{ opacity: [0.25, 1, 0.25] }}
transition={{ repeat: Infinity, duration: 1.6, delay: i * 0.3 }}
/>
))}
</motion.div>
</div>
)
}
// PRESENTING / QA
const phaseLabel = phase.projectPhase === 'QA' ? 'Q&A' : 'Presentation'
return (
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
<div className="space-y-3">
{category && (
<p className="text-base font-semibold uppercase tracking-[0.4em] text-white/45">
{category}
</p>
)}
<h1 className="max-w-[92vw] text-[clamp(3rem,8vw,7rem)] font-extrabold leading-[1.03] tracking-tight">
{project.teamName ?? project.title}
</h1>
{project.teamName && (
<p className="text-2xl font-light text-white/70">{project.title}</p>
)}
</div>
<SignatureRule />
<div className="space-y-2">
<p className="text-lg font-semibold uppercase tracking-[0.45em] text-[#557f8c]">
{phaseLabel}
</p>
{remaining !== null && (
<motion.p
className={`text-[clamp(4rem,9vw,8rem)] font-bold tabular-nums leading-none ${
over ? 'text-[#de0f1e]' : 'text-white'
}`}
animate={over ? { opacity: [1, 0.55, 1] } : {}}
transition={over ? { repeat: Infinity, duration: 1.2 } : {}}
style={over ? { textShadow: '0 0 60px rgba(222,15,30,0.55)' } : {}}
>
{formatClock(remaining)}
</motion.p>
)}
{phase.phasePausedAt && (
<p className="text-base font-semibold uppercase tracking-[0.35em] text-white/45">
paused
</p>
)}
</div>
</div>
)
}
function AudienceVoteSlide({
windowKey,
closesAt,
voteCount,
voteUrl,
}: {
windowKey: string | null
closesAt: string | Date | null
voteCount: number
voteUrl: string
}) {
useTick()
const secondsLeft = closesAt
? Math.max(0, Math.floor((new Date(closesAt).getTime() - Date.now()) / 1000))
: null
return (
<div className="flex h-full items-center justify-center gap-24 px-20">
<motion.div
animate={{ scale: [1, 1.015, 1] }}
transition={{ repeat: Infinity, duration: 4, ease: 'easeInOut' }}
className="shrink-0 rounded-[2.5rem] bg-white p-10 shadow-[0_0_120px_rgba(85,127,140,0.45)]"
>
{voteUrl && <QRCodeSVG value={voteUrl} size={400} />}
</motion.div>
<div className="max-w-3xl space-y-8">
<p className="text-lg font-semibold uppercase tracking-[0.45em] text-[#de0f1e]">
Audience vote open now
</p>
<h1 className="text-[clamp(2.5rem,5.5vw,4.5rem)] font-extrabold leading-tight tracking-tight">
{WINDOW_TITLE[windowKey ?? ''] ?? 'Vote for your favorite'}
</h1>
<p className="text-2xl font-light text-white/70">
Scan the code with your phone one vote each
</p>
<div className="flex items-end gap-14 pt-2">
{secondsLeft !== null && (
<div>
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-white/45">
Closes in
</p>
<p
className={`text-7xl font-bold tabular-nums ${
secondsLeft <= 30 ? 'text-[#de0f1e]' : ''
}`}
>
{formatClock(secondsLeft)}
</p>
</div>
)}
<div>
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-white/45">
Votes cast
</p>
<motion.p
key={voteCount}
initial={{ scale: 1.25, color: '#de0f1e' }}
animate={{ scale: 1, color: '#ffffff' }}
className="text-7xl font-bold tabular-nums"
>
{voteCount}
</motion.p>
</div>
</div>
</div>
</div>
)
}
// ─── Reveal ──────────────────────────────────────────────────────────────────
function Confetti({ gold }: { gold?: boolean }) {
const pieces = useMemo(
() =>
Array.from({ length: 56 }, (_, i) => ({
left: ((i * 137.5) % 100),
delay: (i % 14) * 0.09,
duration: 2.6 + ((i * 7) % 10) / 6,
size: 7 + ((i * 13) % 9),
rotate: (i * 73) % 360,
color: gold
? ['#e8c34a', '#de0f1e', '#ffffff', '#f0d98c'][i % 4]
: ['#de0f1e', '#557f8c', '#ffffff', '#9fc3cf'][i % 4],
})),
[gold]
)
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{pieces.map((p, i) => (
<motion.span
key={i}
className="absolute top-[-5%] block"
style={{
left: `${p.left}%`,
width: p.size,
height: p.size * 0.45,
backgroundColor: p.color,
borderRadius: 1,
}}
initial={{ y: '-10vh', rotate: p.rotate, opacity: 0 }}
animate={{ y: '115vh', rotate: p.rotate + 540, opacity: [0, 1, 1, 0.8] }}
transition={{ duration: p.duration, delay: p.delay, ease: 'easeIn' }}
/>
))}
</div>
)
}
type RevealStep = {
kind: string
category?: string
place?: number
title?: string
subtitle?: string
}
function RevealSlide({ step }: { step: RevealStep }) {
const isWinner = step.kind === 'place' && step.place === 1
const isAudience = step.kind === 'audience-award' || step.kind === 'overall-favorite'
if (step.kind === 'category-intro') {
return (
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
<p className="text-lg font-semibold uppercase tracking-[0.5em] text-white/50">Results</p>
<h1 className="text-[clamp(4rem,9vw,8rem)] font-extrabold tracking-tight">
{step.title ?? CATEGORY_LABEL[step.category ?? ''] ?? ''}
</h1>
<SignatureRule />
</div>
)
}
if (step.kind === 'thanks') {
return <StaticSlide kind="thanks" programName={null} />
}
return (
<div className="relative flex h-full flex-col items-center justify-center gap-10 px-16 text-center">
{(isWinner || isAudience) && <Confetti gold={isWinner} />}
{isWinner && (
<div
className="pointer-events-none absolute inset-0"
style={{
background:
'radial-gradient(55% 45% at 50% 52%, rgba(232,195,74,0.16) 0%, transparent 70%)',
}}
/>
)}
<motion.p
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className={`text-xl font-semibold uppercase tracking-[0.5em] ${
isAudience ? 'text-[#de0f1e]' : isWinner ? 'text-[#e8c34a]' : 'text-[#557f8c]'
}`}
>
{step.subtitle ?? ''}
</motion.p>
<motion.h1
initial={{ opacity: 0, y: 60, scale: 0.92 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: 'spring', stiffness: 70, damping: 14, delay: 0.35 }}
className={`max-w-[92vw] font-extrabold leading-[1.02] tracking-tight ${
isWinner
? 'text-[clamp(4.5rem,11vw,10rem)]'
: 'text-[clamp(3.5rem,8vw,7.5rem)]'
}`}
style={isWinner ? { textShadow: '0 0 90px rgba(232,195,74,0.35)' } : undefined}
>
{step.title ?? ''}
</motion.h1>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8 }}
>
<SignatureRule />
</motion.div>
</div>
)
}
function ResultsSplash() {
return (
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
<motion.p
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{ repeat: Infinity, duration: 2.4 }}
className="text-lg font-semibold uppercase tracking-[0.5em] text-[#de0f1e]"
>
The moment has come
</motion.p>
<h1 className="text-[clamp(4rem,10vw,9rem)] font-extrabold tracking-tight">Results</h1>
<SignatureRule />
</div>
)
}
// ─── Page ────────────────────────────────────────────────────────────────────
export default function CeremonyPage({
params: paramsPromise,
}: {
params: Promise<{ roundId: string }>
}) {
const params = use(paramsPromise)
const { data: state } = trpc.liveVoting.getCeremonyState.useQuery(
{ roundId: params.roundId },
{ refetchInterval: 2000 }
)
const voteUrl =
typeof window !== 'undefined'
? `${window.location.origin}/vote/competition/${params.roundId}`
: ''
if (!state) {
return (
<OceanField>
<div className="flex h-full items-center justify-center" />
</OceanField>
)
}
// Display precedence: override → reveal → audience window → phase → welcome
const reveal = state.reveal
const revealStep =
reveal && (reveal.status === 'REVEALING' || reveal.status === 'DONE')
? ((reveal.steps[reveal.currentStepIndex] ?? null) as RevealStep | null)
: null
let slideKey: string
let slide: React.ReactNode
let statusLabel: string | null = null
if (state.overrideSlide) {
slideKey = `override-${state.overrideSlide}`
slide = <StaticSlide kind={state.overrideSlide} programName={state.programName} />
} else if (reveal && reveal.status === 'ARMED') {
slideKey = 'reveal-armed'
slide = <ResultsSplash />
statusLabel = 'Results'
} else if (revealStep) {
slideKey = `reveal-${reveal!.currentStepIndex}`
slide = <RevealSlide step={revealStep} />
statusLabel = 'Results'
} else if (state.audience.open) {
slideKey = `audience-${state.audience.windowKey}`
slide = (
<AudienceVoteSlide
windowKey={state.audience.windowKey}
closesAt={state.audience.closesAt}
voteCount={state.audience.voteCount}
voteUrl={voteUrl}
/>
)
statusLabel = 'Live'
} else if (state.phase && state.activeProject) {
slideKey = `phase-${state.phase.projectPhase}-${state.activeProject.title}`
slide = <PhaseSlide state={state} />
statusLabel = 'Live'
} else {
slideKey = 'welcome'
slide = <StaticSlide kind="welcome" programName={state.programName} />
}
return (
<OceanField>
<StatusBar programName={state.programName} label={statusLabel} />
<AnimatePresence mode="wait">
<motion.div key={slideKey} className="absolute inset-0" {...slideIn}>
{slide}
</motion.div>
</AnimatePresence>
</OceanField>
)
}

View File

@@ -0,0 +1,327 @@
'use client'
import { Suspense, use, useEffect, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { AlertCircle, CheckCircle2, Loader2, Salad, UtensilsCrossed } from 'lucide-react'
const ALLERGENS = [
'GLUTEN', 'CRUSTACEANS', 'EGGS', 'FISH', 'PEANUTS', 'SOYBEANS', 'MILK',
'TREE_NUTS', 'CELERY', 'MUSTARD', 'SESAME', 'SULPHITES', 'LUPIN', 'MOLLUSCS',
] as const
type Allergen = (typeof ALLERGENS)[number]
interface PageProps {
params: Promise<{ token: string }>
}
function formatTag(t: string): string {
return t.replace('_', ' ').toLowerCase()
}
function formatWhen(d: Date): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'long',
timeStyle: 'short',
}).format(d)
}
function CountdownLabel({ deadline }: { deadline: Date }) {
const [now, setNow] = useState<number>(Date.now())
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [])
const ms = deadline.getTime() - now
if (ms <= 0) return <span className="text-destructive font-medium">closed</span>
const totalSec = Math.floor(ms / 1000)
const hours = Math.floor(totalSec / 3600)
const minutes = Math.floor((totalSec % 3600) / 60)
const seconds = totalSec % 60
if (hours >= 24) {
const days = Math.floor(hours / 24)
return (
<span className="font-medium tabular-nums">
{days}d {hours % 24}h remaining
</span>
)
}
return (
<span className="font-medium tabular-nums">
{hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}:
{seconds.toString().padStart(2, '0')} remaining
</span>
)
}
function FriendlyError({ title, message }: { title: string; message: string }) {
return (
<Card className="mx-auto max-w-xl">
<CardHeader>
<div className="flex items-center gap-2">
<AlertCircle className="text-muted-foreground h-5 w-5" />
<CardTitle>{title}</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">{message}</p>
</CardContent>
</Card>
)
}
function DishPickContent({ token }: { token: string }) {
const { data, isLoading, error } = trpc.lunch.getExternalByToken.useQuery(
{ token },
{ retry: false },
)
const setPick = trpc.lunch.setExternalPick.useMutation()
const [dishId, setDishId] = useState<string>('')
const [allergens, setAllergens] = useState<Allergen[]>([])
const [allergenOther, setAllergenOther] = useState<string>('')
const [hydrated, setHydrated] = useState(false)
const [saved, setSaved] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
useEffect(() => {
if (!hydrated && data) {
setDishId(data.external.dishId ?? '')
setAllergens((data.external.allergens as Allergen[]) ?? [])
setAllergenOther(data.external.allergenOther ?? '')
setHydrated(true)
}
}, [data, hydrated])
if (isLoading) {
return (
<div className="mx-auto max-w-xl space-y-4">
<Skeleton className="h-8 w-2/3" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-48 w-full" />
</div>
)
}
if (error) {
const msg = error.message ?? ''
if (/expired/i.test(msg)) {
return (
<FriendlyError
title="This link has expired"
message="Please contact us at info@monaco-opc.com and we'll sort out your lunch."
/>
)
}
if (/signature|malformed|parseable/i.test(msg)) {
return (
<FriendlyError
title="This link is not valid"
message="Please check your email or contact us at info@monaco-opc.com."
/>
)
}
return (
<FriendlyError
title="Something went wrong"
message={msg || 'Please try again or contact us at info@monaco-opc.com.'}
/>
)
}
if (!data) {
return (
<FriendlyError
title="Not found"
message="Please check your email link or contact us at info@monaco-opc.com."
/>
)
}
const deadline = data.changeDeadline ? new Date(data.changeDeadline) : null
const deadlinePassed = deadline ? new Date() > deadline : false
const eventAt = data.event.eventAt ? new Date(data.event.eventAt) : null
const handleSave = async () => {
setSubmitError(null)
try {
await setPick.mutateAsync({
token,
dishId: dishId || null,
allergens,
allergenOther: allergenOther.trim() || null,
})
setSaved(true)
} catch (err) {
setSubmitError(err instanceof Error ? err.message : 'Failed to save')
}
}
const eventCard = (
<Card className="border-primary/40 bg-primary/5">
<CardHeader>
<div className="flex items-center gap-2">
<UtensilsCrossed className="text-primary h-5 w-5" />
<CardTitle>
{data.event.venue ? `Lunch at ${data.event.venue}` : 'Lunch'}
</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-1 text-sm">
<p>
Hi <strong>{data.external.name}</strong>, please choose your dish below.
</p>
{eventAt && (
<p className="text-muted-foreground">
<strong>When:</strong> {formatWhen(eventAt)}
</p>
)}
{data.event.notes && (
<p className="text-muted-foreground">{data.event.notes}</p>
)}
{deadline && !deadlinePassed && (
<p className="text-muted-foreground pt-1">
Choose by {formatWhen(deadline)} · <CountdownLabel deadline={deadline} />
</p>
)}
</CardContent>
</Card>
)
// Past the change deadline → read-only.
if (deadlinePassed) {
const chosen = data.dishes.find((d) => d.id === data.external.dishId)
return (
<div className="mx-auto max-w-xl space-y-6">
{eventCard}
<FriendlyError
title="Dish selection is now closed"
message={
chosen
? `Your choice is "${chosen.name}". To change it, please contact us at info@monaco-opc.com.`
: 'The deadline to choose a dish has passed. Please contact us at info@monaco-opc.com.'
}
/>
</div>
)
}
return (
<div className="mx-auto max-w-xl space-y-6">
{eventCard}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Salad className="h-4 w-4 text-emerald-600" /> Your dish
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<RadioGroup value={dishId} onValueChange={setDishId} className="gap-2">
{data.dishes.map((d) => (
<label
key={d.id}
htmlFor={`dish-${d.id}`}
className="hover:bg-muted/50 flex cursor-pointer items-start gap-3 rounded-md border p-3"
>
<RadioGroupItem id={`dish-${d.id}`} value={d.id} className="mt-0.5" />
<div>
<div className="font-medium">{d.name}</div>
{d.dietaryTags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{d.dietaryTags.map((t) => (
<Badge key={t} variant="secondary" className="text-xs">
{formatTag(t)}
</Badge>
))}
</div>
)}
</div>
</label>
))}
{data.dishes.length === 0 && (
<p className="text-muted-foreground text-sm">
No dishes have been published yet. Please check back later.
</p>
)}
</RadioGroup>
<div>
<Label className="text-sm">Allergens</Label>
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-3">
{ALLERGENS.map((a) => (
<label key={a} className="flex items-center gap-2 text-sm">
<Checkbox
checked={allergens.includes(a)}
onCheckedChange={(v) =>
setAllergens(
v ? [...allergens, a] : allergens.filter((x) => x !== a),
)
}
/>
{formatTag(a)}
</label>
))}
</div>
</div>
<div>
<Label className="text-sm">Other allergens / dietary notes</Label>
<Textarea
value={allergenOther}
onChange={(e) => {
setAllergenOther(e.target.value)
setSaved(false)
}}
rows={2}
className="mt-1"
placeholder="e.g. severe nut allergy, no shellfish"
/>
</div>
</CardContent>
</Card>
{submitError && (
<div className="border-destructive bg-destructive/10 text-destructive rounded-md border p-3 text-sm">
{submitError}
</div>
)}
<div className="flex items-center justify-between gap-4">
{saved && !setPick.isPending ? (
<span className="flex items-center gap-2 text-sm text-emerald-600">
<CheckCircle2 className="h-4 w-4" /> Saved you can change it until the deadline.
</span>
) : (
<span />
)}
<Button size="lg" onClick={handleSave} disabled={setPick.isPending}>
{setPick.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Saving
</>
) : (
<>
<CheckCircle2 className="mr-2 h-4 w-4" /> Save my choice
</>
)}
</Button>
</div>
</div>
)
}
export default function LunchPickPage({ params }: PageProps) {
const { token } = use(params)
return (
<Suspense fallback={<Skeleton className="h-64 w-full" />}>
<DishPickContent token={token} />
</Suspense>
)
}

View File

@@ -1,88 +1,274 @@
'use client';
'use client'
import { use, useEffect, useState } from 'react';
import { trpc } from '@/lib/trpc/client';
import { Card, CardContent } from '@/components/ui/card';
import { AudienceVoteCard } from '@/components/public/audience-vote-card';
import { toast } from 'sonner';
/**
* Audience voting page — reached by scanning the QR code on the big screen.
* Zero-instruction flow: scan → (auto token) → wait → tap your favorite →
* done. Votes can be changed until the window closes. Uses ONLY public
* procedures: attendees have no account.
*/
export default function AudienceVotePage({ params: paramsPromise }: { params: Promise<{ roundId: string }> }) {
const params = use(paramsPromise);
const utils = trpc.useUtils();
const [hasVoted, setHasVoted] = useState(false);
import { use, useEffect, useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { trpc } from '@/lib/trpc/client'
import { formatClock } from '@/lib/live-timer'
import { Check, Heart, Hourglass, Vote } from 'lucide-react'
import { toast } from 'sonner'
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId: params.roundId });
const WINDOW_TITLE: Record<string, string> = {
'CATEGORY:BUSINESS_CONCEPT': 'Pick your favorite Business Concept',
'CATEGORY:STARTUP': 'Pick your favorite Startup',
OVERALL: 'Pick your favorite project of the night',
}
const submitVoteMutation = trpc.liveVoting.castAudienceVote.useMutation({
onSuccess: () => {
setHasVoted(true);
// Store in localStorage to prevent duplicate votes
if (cursor?.activeProject?.id) {
localStorage.setItem(`voted-${params.roundId}-${cursor.activeProject.id}`, 'true');
}
toast.success('Vote submitted! Thank you for participating.');
},
onError: (err) => {
toast.error(err.message);
}
});
// Check localStorage on mount
function useCountdown(closesAt: string | Date | null | undefined) {
const [, tick] = useState(0)
useEffect(() => {
if (cursor?.activeProject?.id) {
const voted = localStorage.getItem(`voted-${params.roundId}-${cursor.activeProject.id}`);
if (voted === 'true') {
setHasVoted(true);
}
const id = setInterval(() => tick((t) => t + 1), 1000)
return () => clearInterval(id)
}, [])
if (!closesAt) return null
return Math.max(0, Math.floor((new Date(closesAt).getTime() - Date.now()) / 1000))
}
export default function AudienceVotePage({
params: paramsPromise,
}: {
params: Promise<{ roundId: string }>
}) {
const params = use(paramsPromise)
const utils = trpc.useUtils()
const { data: context, isLoading: contextLoading } =
trpc.liveVoting.getAudienceContextByRound.useQuery({ roundId: params.roundId })
const sessionId = context?.sessionId ?? null
// ── Anonymous voter token, persisted per session in this browser ─────────
const [token, setToken] = useState<string | null>(null)
const register = trpc.liveVoting.registerAudienceVoter.useMutation({
onSuccess: (res) => {
if (sessionId) localStorage.setItem(`mopc-audience-${sessionId}`, res.token)
setToken(res.token)
},
})
useEffect(() => {
if (!sessionId || !context?.allowAudienceVotes) return
const stored = localStorage.getItem(`mopc-audience-${sessionId}`)
if (stored) {
setToken(stored)
} else if (!register.isPending && !token) {
register.mutate({ sessionId })
}
}, [cursor?.activeProject?.id, params.roundId]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, context?.allowAudienceVotes])
const handleVote = () => {
if (!cursor?.activeProject?.id) return;
const { data: win } = trpc.liveVoting.getAudienceWindow.useQuery(
{ sessionId: sessionId ?? '', token: token ?? undefined },
{ enabled: !!sessionId, refetchInterval: 3000 }
)
submitVoteMutation.mutate({
projectId: cursor.activeProject.id,
sessionId: params.roundId,
score: 1,
token: `audience-${Date.now()}`
});
};
const [selected, setSelected] = useState<string | null>(null)
const cast = trpc.liveVoting.castFavoriteVote.useMutation({
onSuccess: () => {
utils.liveVoting.getAudienceWindow.invalidate()
setSelected(null)
toast.success('Vote recorded!')
},
onError: (err) => toast.error(err.message),
})
if (!cursor?.activeProject) {
const secondsLeft = useCountdown(win?.closesAt)
const open = !!win?.open && (secondsLeft === null || secondsLeft > 0)
const myVote = win?.myVoteProjectId ?? null
// Reset local selection when a new window opens
useEffect(() => {
setSelected(null)
}, [win?.windowKey])
if (contextLoading) {
return <CenteredState icon={Hourglass} title="Loading…" />
}
if (!context) {
return (
<div className="container mx-auto flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-center text-lg text-muted-foreground">
No project is currently being presented
</p>
<p className="mt-2 text-center text-sm text-muted-foreground">
Please wait for the ceremony to begin
</p>
</CardContent>
</Card>
</div>
);
<CenteredState
icon={Vote}
title="No vote here yet"
subtitle="This voting link isn't active. Keep an eye on the big screen!"
/>
)
}
if (!context.allowAudienceVotes) {
return (
<CenteredState
icon={Vote}
title="Audience voting is not open"
subtitle="Voting will be enabled during the event."
/>
)
}
return (
<div className="container mx-auto flex min-h-screen items-center justify-center p-4">
<div className="w-full">
<div className="mb-8 text-center">
<h1 className="text-4xl font-bold text-[#053d57]">Monaco Ocean Protection Challenge</h1>
<p className="mt-2 text-lg text-muted-foreground">Live Audience Voting</p>
</div>
<AudienceVoteCard
project={cursor.activeProject}
onVote={handleVote}
hasVoted={hasVoted}
/>
<p className="mt-6 text-center text-sm text-muted-foreground">
Live voting in progress
<div className="mx-auto max-w-lg">
{/* Gala header */}
<div className="-mx-4 -mt-8 mb-6 bg-gradient-to-br from-[#021f2e] via-[#053d57] to-[#0a5a7c] px-6 py-8 text-center text-white sm:rounded-b-2xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.35em] text-white/60">
{context.programName ?? 'Grand Finale'}
</p>
<h1 className="mt-2 text-2xl font-bold">Audience Vote</h1>
{open && secondsLeft !== null && (
<div className="mx-auto mt-3 inline-flex items-center gap-2 rounded-full bg-white/10 px-4 py-1.5 text-sm tabular-nums">
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-[#de0f1e]" />
closes in {formatClock(secondsLeft)}
</div>
)}
</div>
<AnimatePresence mode="wait">
{!open ? (
<motion.div
key="waiting"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="py-10 text-center"
>
<motion.div
animate={{ y: [0, -6, 0] }}
transition={{ repeat: Infinity, duration: 2.4, ease: 'easeInOut' }}
className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[#053d57]/8"
>
<Hourglass className="h-7 w-7 text-[#053d57]" />
</motion.div>
<h2 className="text-lg font-semibold text-[#053d57]">
Voting opens after the presentations
</h2>
<p className="mx-auto mt-2 max-w-xs text-sm text-muted-foreground">
Keep this page open the ballot appears here the moment voting starts.
</p>
</motion.div>
) : (
<motion.div
key={`open-${win?.windowKey}`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="space-y-3 pb-28"
>
<h2 className="text-center text-lg font-semibold text-[#053d57]">
{WINDOW_TITLE[win?.windowKey ?? ''] ?? 'Pick your favorite'}
</h2>
<p className="text-center text-xs text-muted-foreground">
One vote you can change it until voting closes
</p>
<div className="space-y-2.5 pt-2">
{(win?.projects ?? []).map((project) => {
const isPicked = (selected ?? myVote) === project.id
return (
<button
key={project.id}
onClick={() => setSelected(project.id)}
className={`w-full rounded-2xl border-2 p-4 text-left transition-all active:scale-[0.99] ${
isPicked
? 'border-[#de0f1e] bg-[#053d57] text-white shadow-lg'
: 'border-border bg-card hover:border-[#557f8c]'
}`}
>
<div className="flex items-center gap-3">
<div
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${
isPicked ? 'bg-[#de0f1e]' : 'bg-[#053d57]/8'
}`}
>
{isPicked ? (
<Check className="h-5 w-5 text-white" />
) : (
<Heart className="h-4 w-4 text-[#557f8c]" />
)}
</div>
<div className="min-w-0">
<p className="truncate font-semibold">
{project.teamName ?? project.title}
</p>
{project.teamName && (
<p
className={`truncate text-xs ${
isPicked ? 'text-white/70' : 'text-muted-foreground'
}`}
>
{project.title}
</p>
)}
</div>
</div>
</button>
)
})}
</div>
{/* Pinned confirm bar */}
<AnimatePresence>
{selected && selected !== myVote && (
<motion.div
initial={{ y: 80 }}
animate={{ y: 0 }}
exit={{ y: 80 }}
className="fixed inset-x-0 bottom-0 z-40 border-t bg-background/95 p-4 pb-[max(1rem,env(safe-area-inset-bottom))] backdrop-blur"
>
<div className="mx-auto max-w-lg">
<button
onClick={() =>
sessionId &&
token &&
cast.mutate({ sessionId, token, projectId: selected })
}
disabled={cast.isPending || !token}
className="w-full rounded-xl bg-[#de0f1e] py-3.5 font-semibold text-white shadow-lg transition-transform active:scale-[0.98] disabled:opacity-60"
>
{cast.isPending
? 'Submitting…'
: myVote
? 'Change my vote'
: 'Confirm my vote'}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{myVote && (!selected || selected === myVote) && (
<div className="pt-2 text-center">
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-600/10 px-4 py-1.5 text-sm font-medium text-green-700">
<Check className="h-4 w-4" />
Vote recorded tap another to change it
</span>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
)
}
function CenteredState({
icon: Icon,
title,
subtitle,
}: {
icon: typeof Vote
title: string
subtitle?: string
}) {
return (
<div className="py-16 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[#053d57]/8">
<Icon className="h-7 w-7 text-[#053d57]" />
</div>
<h2 className="text-lg font-semibold text-[#053d57]">{title}</h2>
{subtitle && (
<p className="mx-auto mt-2 max-w-xs text-sm text-muted-foreground">{subtitle}</p>
)}
</div>
)
}

View File

@@ -0,0 +1,17 @@
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { sendDueFinalDocReminders } from '@/server/services/final-documents'
export async function GET(request: NextRequest): Promise<NextResponse> {
const cronSecret = request.headers.get('x-cron-secret')
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const result = await sendDueFinalDocReminders(prisma)
return NextResponse.json({ ok: true, ...result })
} catch (error) {
console.error('[Cron] final-document-reminders failed:', error)
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
}
}

View File

@@ -1,6 +1,9 @@
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { expirePendingPastDeadline } from '@/server/services/finalist-confirmation'
import {
expirePendingPastDeadline,
sendDueConfirmationReminders,
} from '@/server/services/finalist-confirmation'
export async function GET(request: NextRequest): Promise<NextResponse> {
const cronSecret = request.headers.get('x-cron-secret')
@@ -8,8 +11,11 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const result = await expirePendingPastDeadline(prisma)
return NextResponse.json({ ok: true, ...result })
const [expireResult, reminderResult] = await Promise.all([
expirePendingPastDeadline(prisma),
sendDueConfirmationReminders(prisma),
])
return NextResponse.json({ ok: true, ...expireResult, ...reminderResult })
} catch (error) {
console.error('[Cron] finalist-confirmations failed:', error)
return NextResponse.json({ error: 'Internal error' }, { status: 500 })

View File

@@ -1,6 +1,11 @@
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { sendLunchReminderEmail } from '@/lib/email'
import {
selectUnpickedAttendees,
selectUnpickedExternals,
} from '@/server/services/lunch-reminders'
import { sendExternalDishInvite } from '@/server/services/lunch-external-invite'
/**
* Cron: send a single reminder email per attending member who hasn't picked
@@ -35,16 +40,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
)
if (now < reminderAt || now >= deadline) continue
const ams = await prisma.attendingMember.findMany({
where: {
confirmation: {
project: { programId: event.programId },
status: 'CONFIRMED',
},
lunchPick: { is: { pickedAt: null } },
},
include: { user: { select: { name: true, email: true } } },
})
const ams = await selectUnpickedAttendees(prisma, event)
for (const am of ams) {
if (!am.user.email) continue
try {
@@ -61,6 +57,19 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
console.error('[lunch-reminders] send failed for', am.user.email, e)
}
}
// External attendees: emailed + no dish yet → their tokenized pick page.
const externals = await selectUnpickedExternals(prisma, { id: event.id })
for (const ext of externals) {
if (!ext.email) continue
try {
await sendExternalDishInvite(prisma, ext, event)
sent++
} catch (e) {
console.error('[lunch-reminders] external send failed for', ext.email, e)
}
}
await prisma.lunchEvent.update({
where: { id: event.id },
data: { reminderSentAt: new Date() },

View File

@@ -1,7 +1,7 @@
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/server/routers/_app'
import { createContext } from '@/server/context'
import { checkRateLimit } from '@/lib/rate-limit'
import { checkRateLimit, isCeremonyTraffic } from '@/lib/rate-limit'
// Allow long-running operations (AI filtering, bulk imports)
// This affects Next.js serverless functions; for self-hosted, Nginx timeout also matters
@@ -9,6 +9,9 @@ export const maxDuration = 300 // 5 minutes
const RATE_LIMIT = 100 // requests per window
const RATE_WINDOW_MS = 60 * 1000 // 1 minute
// Ceremony-day polling: whole venues share one IP (NAT) and every screen
// polls a few cheap public reads — see CEREMONY_PROCEDURES in lib/rate-limit.
const CEREMONY_RATE_LIMIT = 6000
function getClientIp(req: Request): string {
return (
@@ -20,7 +23,10 @@ function getClientIp(req: Request): string {
const handler = (req: Request) => {
const ip = getClientIp(req)
const { success, remaining, resetAt } = checkRateLimit(`trpc:${ip}`, RATE_LIMIT, RATE_WINDOW_MS)
const ceremony = isCeremonyTraffic(new URL(req.url).pathname)
const { success, remaining, resetAt } = ceremony
? checkRateLimit(`trpc-ceremony:${ip}`, CEREMONY_RATE_LIMIT, RATE_WINDOW_MS)
: checkRateLimit(`trpc:${ip}`, RATE_LIMIT, RATE_WINDOW_MS)
if (!success) {
return new Response(JSON.stringify({ error: 'Too many requests' }), {

View File

@@ -91,7 +91,9 @@ export function AdminOverrideDialog({
<Label>Project Rankings</Label>
<div className="space-y-2">
{projectIds.map((projectId) => {
const project = session?.results?.find((r) => r.project.id === projectId)?.project;
const project =
(session as any)?.projects?.find((p: any) => p.id === projectId) ??
session?.results?.find((r) => r.project.id === projectId)?.project;
return (
<div key={projectId} className="flex items-center gap-3">
<Input

View File

@@ -0,0 +1,260 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ResultsPanel } from './results-panel'
import { AdminOverrideDialog } from './admin-override-dialog'
import { Gavel, Lock, Play, Plus, Square, Users } from 'lucide-react'
import { toast } from 'sonner'
const CATEGORY_LABEL: Record<string, string> = {
BUSINESS_CONCEPT: 'Business Concepts',
STARTUP: 'Startups',
}
const STATUS_BADGE: Record<string, { label: string; variant: 'secondary' | 'default' | 'destructive' | 'outline' }> = {
DELIB_OPEN: { label: 'Open — voting not started', variant: 'secondary' },
VOTING: { label: 'Voting', variant: 'default' },
TALLYING: { label: 'Tallying', variant: 'outline' },
RUNOFF: { label: 'Runoff', variant: 'destructive' },
DELIB_LOCKED: { label: 'Locked', variant: 'secondary' },
}
function SessionCard({ session, competitionId }: { session: any; competitionId: string }) {
const utils = trpc.useUtils()
const [overrideOpen, setOverrideOpen] = useState(false)
const { data: detail } = trpc.deliberation.getSession.useQuery(
{ sessionId: session.id },
{ refetchInterval: 10_000 }
)
const invalidate = () => {
utils.deliberation.getSession.invalidate({ sessionId: session.id })
utils.deliberation.listSessions.invalidate({ competitionId })
}
const onError = (err: { message: string }) => toast.error(err.message)
const openVoting = trpc.deliberation.openVoting.useMutation({
onSuccess: () => {
invalidate()
toast.success('Deliberation voting opened — jurors can now rank')
},
onError,
})
const closeVoting = trpc.deliberation.closeVoting.useMutation({
onSuccess: () => {
invalidate()
toast.success('Voting closed — tallying')
},
onError,
})
const status = detail?.status ?? session.status
const badge = STATUS_BADGE[status] ?? { label: status, variant: 'outline' as const }
const voteCount = detail?.votes?.length ?? session._count?.votes ?? 0
const participantCount = detail?.participants?.length ?? session._count?.participants ?? 0
const votedJurors = new Set(
(detail?.votes ?? []).map((v: any) => v.juryMember?.id ?? v.juryMemberId)
).size
const projectIds: string[] =
detail?.projects?.map((p: any) => p.id) ??
detail?.results?.map((r: any) => r.project.id) ??
[]
return (
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<CardTitle className="text-lg">
{CATEGORY_LABEL[session.category] ?? session.category}
</CardTitle>
<CardDescription className="mt-0.5 flex items-center gap-3">
<span className="flex items-center gap-1">
<Users className="h-3.5 w-3.5" />
{votedJurors}/{participantCount} jurors voted
</span>
<span>{voteCount} ballots</span>
<span>{session.mode === 'FULL_RANKING' ? 'Full ranking' : 'Single winner'}</span>
</CardDescription>
</div>
<Badge variant={badge.variant}>{badge.label}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
{status === 'DELIB_OPEN' && (
<Button
onClick={() => openVoting.mutate({ sessionId: session.id })}
disabled={openVoting.isPending}
>
<Play className="mr-2 h-4 w-4" />
Open voting
</Button>
)}
{(status === 'VOTING' || status === 'RUNOFF') && (
<Button
variant="outline"
onClick={() => closeVoting.mutate({ sessionId: session.id })}
disabled={closeVoting.isPending}
>
<Square className="mr-2 h-4 w-4" />
Close voting & tally
</Button>
)}
{status !== 'DELIB_LOCKED' && (
<Button variant="outline" onClick={() => setOverrideOpen(true)}>
<Gavel className="mr-2 h-4 w-4" />
Set rankings manually
</Button>
)}
{status === 'DELIB_LOCKED' && (
<Badge variant="secondary" className="gap-1 py-1.5">
<Lock className="h-3.5 w-3.5" />
Results locked
</Badge>
)}
</div>
{/* Aggregated results + runoff/finalize controls */}
{(status === 'TALLYING' || status === 'RUNOFF' || status === 'DELIB_LOCKED') && (
<ResultsPanel sessionId={session.id} />
)}
<AdminOverrideDialog
sessionId={session.id}
open={overrideOpen}
onOpenChange={setOverrideOpen}
projectIds={projectIds}
/>
</CardContent>
</Card>
)
}
/**
* Admin deliberation console for a DELIBERATION round: create the per-category
* sessions from the round's jury group, drive voting open/close, tally,
* resolve ties, override manually (the "jury went analog" path) and finalize.
*/
export function DeliberationControlPanel({
roundId,
competitionId,
}: {
roundId: string
competitionId: string
}) {
const utils = trpc.useUtils()
const { data: round } = trpc.round.getById.useQuery({ id: roundId })
const juryGroupId = round?.juryGroupId ?? ''
const { data: juryGroup } = trpc.juryGroup.getById.useQuery(
{ id: juryGroupId },
{ enabled: !!juryGroupId }
)
const { data: sessions } = trpc.deliberation.listSessions.useQuery(
{ competitionId },
{ refetchInterval: 15_000 }
)
const [mode, setMode] = useState<'FULL_RANKING' | 'SINGLE_WINNER_VOTE'>('FULL_RANKING')
const createSession = trpc.deliberation.createSession.useMutation({
onSuccess: () => {
utils.deliberation.listSessions.invalidate({ competitionId })
toast.success('Deliberation session created')
},
onError: (err) => toast.error(err.message),
})
const roundSessions = (sessions ?? []).filter((s: any) => s.roundId === roundId)
const existingCategories = new Set(roundSessions.map((s: any) => s.category))
const votingMembers = (juryGroup?.members ?? []).filter((m: any) => m.role !== 'OBSERVER')
const handleCreate = (category: 'STARTUP' | 'BUSINESS_CONCEPT') => {
if (votingMembers.length === 0) {
toast.error('The round has no jury group members to deliberate')
return
}
createSession.mutate({
competitionId,
roundId,
category,
mode,
tieBreakMethod: 'TIE_ADMIN_DECIDES',
showPriorJuryData: true,
participantUserIds: votingMembers.map((m: any) => m.id),
})
}
return (
<div className="space-y-4">
{(['BUSINESS_CONCEPT', 'STARTUP'] as const).some((c) => !existingCategories.has(c)) && (
<Card>
<CardHeader>
<CardTitle>Create Deliberation Sessions</CardTitle>
<CardDescription>
One session per category · participants come from the round&apos;s jury group (
{votingMembers.length} voting member{votingMembers.length === 1 ? '' : 's'})
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-end gap-3">
<div className="space-y-1">
<Label className="text-xs">Mode</Label>
<Select value={mode} onValueChange={(v) => setMode(v as typeof mode)}>
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="FULL_RANKING">Full ranking (Borda)</SelectItem>
<SelectItem value="SINGLE_WINNER_VOTE">Single winner pick</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
{(['BUSINESS_CONCEPT', 'STARTUP'] as const)
.filter((c) => !existingCategories.has(c))
.map((category) => (
<Button
key={category}
variant="outline"
onClick={() => handleCreate(category)}
disabled={createSession.isPending || !juryGroupId}
>
<Plus className="mr-2 h-4 w-4" />
{CATEGORY_LABEL[category]}
</Button>
))}
</div>
</div>
{!juryGroupId && (
<p className="text-xs text-destructive">
Assign a jury group to this round first (Config tab).
</p>
)}
</CardContent>
</Card>
)}
{roundSessions.map((s: any) => (
<SessionCard key={s.id} session={s} competitionId={competitionId} />
))}
{roundSessions.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
No deliberation sessions yet create one per category above.
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,159 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Loader2 } from 'lucide-react'
export type AttendeeSelection = {
attendingUserIds: string[]
visaFlags: Record<string, boolean>
}
type Member = {
userId: string
name: string | null
role: string
email: string
}
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
members: Member[]
cap: number
onConfirm: (attendingUserIds: string[], visaFlags: Record<string, boolean>) => void
initial?: AttendeeSelection
isPending?: boolean
}
export function EnrollAttendeesDialog({
open,
onOpenChange,
members,
cap,
onConfirm,
initial,
isPending = false,
}: Props) {
const [selected, setSelected] = useState<Set<string>>(new Set())
const [visa, setVisa] = useState<Record<string, boolean>>({})
// Seed from initial or default to first member (the lead)
useEffect(() => {
if (!open) return
if (initial) {
setSelected(new Set(initial.attendingUserIds))
setVisa(initial.visaFlags)
} else {
const defaultSelected = members.slice(0, 1).map((m) => m.userId)
setSelected(new Set(defaultSelected))
setVisa({})
}
}, [open, initial, members])
const overCap = selected.size > cap
const noneSelected = selected.size === 0
const toggleMember = (userId: string, checked: boolean) => {
setSelected((prev) => {
const next = new Set(prev)
if (checked) next.add(userId)
else next.delete(userId)
return next
})
}
const handleConfirm = () => {
const ids = Array.from(selected)
onConfirm(
ids,
Object.fromEntries(ids.map((id) => [id, !!visa[id]])),
)
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!isPending) onOpenChange(next)
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Select attendees</DialogTitle>
<DialogDescription>
Choose up to {cap} team member{cap === 1 ? '' : 's'} who will attend. Toggle
&quot;Visa?&quot; for anyone who needs a visa letter.
</DialogDescription>
</DialogHeader>
<ul className="max-h-[50vh] space-y-2 overflow-y-auto pr-1">
{members.map((m) => {
const checked = selected.has(m.userId)
const atCap = !checked && selected.size >= cap
return (
<li
key={m.userId}
className="flex items-start justify-between gap-4 rounded-md border px-3 py-2"
>
<label className="flex flex-1 cursor-pointer items-start gap-3">
<Checkbox
checked={checked}
disabled={atCap}
onCheckedChange={(c) => toggleMember(m.userId, c === true)}
className="mt-0.5"
/>
<div>
<div className="text-sm font-medium">{m.name ?? m.email}</div>
<div className="text-muted-foreground text-xs">
{m.email}
{m.role && m.role !== 'MEMBER' ? ` · ${m.role.toLowerCase()}` : ''}
</div>
</div>
</label>
{checked && (
<label className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Visa?</span>
<Switch
checked={!!visa[m.userId]}
onCheckedChange={(c) => setVisa((prev) => ({ ...prev, [m.userId]: c }))}
/>
</label>
)}
</li>
)
})}
</ul>
{overCap && (
<p className="text-destructive text-sm">
Please select no more than {cap} member{cap === 1 ? '' : 's'}.
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={overCap || noneSelected || isPending}
>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Confirm attendees
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,82 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { EmailPreviewDialog } from '@/components/admin/round/email-preview-dialog'
import { Eye, Mail, Send } from 'lucide-react'
import { toast } from 'sonner'
const REMINDER_TYPE = 'GRAND_FINAL_DOCS_REMINDER'
export function FinalDocsReminderButton({ programId }: { programId: string }) {
const [open, setOpen] = useState(false)
const [previewOpen, setPreviewOpen] = useState(false)
const preview = trpc.notification.previewEmailTemplate.useQuery(
{ notificationType: REMINDER_TYPE },
{ enabled: previewOpen },
)
const send = trpc.finalist.sendDocumentReminders.useMutation({
onSuccess: (r) => {
toast.success(`Reminder sent to ${r.sent} team${r.sent === 1 ? '' : 's'}`)
setOpen(false)
},
onError: (e) => toast.error(e.message),
})
return (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Mail className="mr-2 h-4 w-4" /> Remind teams to upload final documents
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Remind finalist teams</DialogTitle>
<DialogDescription>
Sends an in-app + email reminder to every finalist team with missing required
documents.
</DialogDescription>
</DialogHeader>
<DialogFooter className="sm:justify-between">
<Button variant="ghost" size="sm" onClick={() => setPreviewOpen(true)}>
<Eye className="mr-2 h-4 w-4" /> Preview email
</Button>
<Button onClick={() => send.mutate({ programId })} disabled={send.isPending}>
<Send className="mr-2 h-4 w-4" /> {send.isPending ? 'Sending…' : 'Send reminders'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Self-contained preview dialog — rendered as a sibling so it is not nested
inside the confirm dialog's content. */}
<EmailPreviewDialog
open={previewOpen}
onOpenChange={setPreviewOpen}
title="Final Documents Reminder"
description="Preview of the email finalist teams receive."
recipientCount={0}
previewHtml={preview.data?.html}
isPreviewLoading={preview.isLoading}
onSend={() => {}}
isSending={false}
previewOnly
showCustomMessage={false}
/>
</>
)
}

View File

@@ -0,0 +1,37 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { toast } from 'sonner'
/**
* Admin toggle: whether finalist teams may upload *revised* grand-final documents.
* Off by default — judges always see the teams' existing prior-round submissions
* regardless; this only controls whether teams are prompted/allowed to upload new
* revised versions (and whether the upload reminder cron runs).
*/
export function FinalDocsUploadsToggle({ roundId }: { roundId: string }) {
const utils = trpc.useUtils()
const { data } = trpc.finalist.getRevisedUploadSetting.useQuery({ roundId })
const set = trpc.finalist.setRevisedUploadSetting.useMutation({
onSuccess: (r) => {
toast.success(r.enabled ? 'Finalist revised uploads enabled' : 'Finalist revised uploads disabled')
utils.finalist.getRevisedUploadSetting.invalidate({ roundId })
},
onError: (e) => toast.error(e.message),
})
return (
<div className="flex items-center gap-2">
<Switch
id="finalist-revised-uploads"
checked={!!data?.enabled}
disabled={set.isPending}
onCheckedChange={(v) => set.mutate({ roundId, enabled: v })}
/>
<Label htmlFor="finalist-revised-uploads" className="text-sm text-muted-foreground cursor-pointer">
Allow finalists to upload revised documents
</Label>
</div>
)
}

View File

@@ -0,0 +1,456 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Loader2, UserCheck } from 'lucide-react'
import { formatEnumLabel } from '@/lib/utils'
import {
EnrollAttendeesDialog,
type AttendeeSelection,
} from './enroll-attendees-dialog'
interface Props {
programId: string
roundId: string
}
type EnrollMode = 'EMAIL' | 'ADMIN_CONFIRM'
type RowState = {
mode: EnrollMode
attendees?: AttendeeSelection
}
type Candidate = {
projectId: string
title: string
teamName: string | null
country: string | null
inLiveFinal: boolean
confirmationStatus: string | null
teamMembers: Array<{ userId: string; name: string | null; role: string; email: string }>
}
const STATUS_CONFIG: Record<
string,
{ label: string; variant: 'default' | 'secondary' | 'outline' | 'destructive' }
> = {
PENDING: { label: 'Pending', variant: 'secondary' },
CONFIRMED: { label: 'Confirmed', variant: 'default' },
DECLINED: { label: 'Declined', variant: 'destructive' },
EXPIRED: { label: 'Expired', variant: 'outline' },
}
function deriveStatus(candidate: Candidate): string {
if (candidate.confirmationStatus) return candidate.confirmationStatus
if (candidate.inLiveFinal) return 'IN_ROUND'
return 'NOT_ENROLLED'
}
function StatusBadge({ candidate }: { candidate: Candidate }) {
const status = deriveStatus(candidate)
if (status === 'NOT_ENROLLED') {
return (
<Badge variant="outline" className="text-xs">
Not enrolled
</Badge>
)
}
if (status === 'IN_ROUND') {
return (
<Badge variant="secondary" className="text-xs">
In round
</Badge>
)
}
const cfg = STATUS_CONFIG[status] ?? { label: status, variant: 'outline' as const }
return (
<Badge variant={cfg.variant} className="text-xs">
{cfg.label}
</Badge>
)
}
export function FinalistEnrollmentCard({ programId, roundId }: Props) {
const utils = trpc.useUtils()
const { data, isLoading } = trpc.finalist.listEnrollmentCandidates.useQuery({ programId })
// Per-row selection + mode state
const [selected, setSelected] = useState<Set<string>>(new Set())
const [rowState, setRowState] = useState<Record<string, RowState>>({})
// Dialog state for "Set attendees now" picker
const [attendeesDialog, setAttendeesDialog] = useState<{
open: boolean
projectId: string
members: Candidate['teamMembers']
} | null>(null)
// Un-enroll state
const [unenrolling, setUnenrolling] = useState<string | null>(null)
const invalidateQueries = () => {
utils.finalist.listEnrollmentCandidates.invalidate({ programId })
utils.logistics.listConfirmations.invalidate({ programId })
}
const enrollMutation = trpc.finalist.enrollFinalists.useMutation({
onSuccess: (result) => {
const parts: string[] = []
if (result.enrolled > 0) parts.push(`${result.enrolled} enrolled`)
if (result.emailed > 0) parts.push(`${result.emailed} emailed`)
if (result.adminConfirmed > 0) parts.push(`${result.adminConfirmed} admin-confirmed`)
if (result.skipped.length > 0) parts.push(`${result.skipped.length} skipped`)
toast.success(parts.join(' · ') || 'Done')
setSelected(new Set())
setRowState({})
invalidateQueries()
},
onError: (err) => toast.error(err.message),
})
const unenrollMutation = trpc.finalist.unenroll.useMutation({
onSuccess: () => {
toast.success('Team removed from the Grand Final round')
setUnenrolling(null)
invalidateQueries()
},
onError: (err) => {
toast.error(err.message)
setUnenrolling(null)
},
})
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const toggleRow = (projectId: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(projectId)) {
next.delete(projectId)
} else {
next.add(projectId)
}
return next
})
setRowState((prev) => {
if (prev[projectId]) return prev
return { ...prev, [projectId]: { mode: 'EMAIL' } }
})
}
const setMode = (projectId: string, mode: EnrollMode, candidate: Candidate) => {
if (mode === 'ADMIN_CONFIRM') {
setAttendeesDialog({
open: true,
projectId,
members: candidate.teamMembers,
})
} else {
setRowState((prev) => ({
...prev,
[projectId]: { mode: 'EMAIL' },
}))
}
}
const handleAttendeesConfirm = (
projectId: string,
attendingUserIds: string[],
visaFlags: Record<string, boolean>,
) => {
setRowState((prev) => ({
...prev,
[projectId]: {
mode: 'ADMIN_CONFIRM',
attendees: { attendingUserIds, visaFlags },
},
}))
setAttendeesDialog(null)
}
const buildEnrollments = (projectIds: string[]) => {
return projectIds.map((projectId) => {
const rs = rowState[projectId] ?? { mode: 'EMAIL' as EnrollMode }
if (rs.mode === 'ADMIN_CONFIRM' && rs.attendees) {
return {
projectId,
mode: 'ADMIN_CONFIRM' as const,
attendingUserIds: rs.attendees.attendingUserIds,
visaFlags: rs.attendees.visaFlags,
}
}
return { projectId, mode: 'EMAIL' as const }
})
}
const handleEnrollSelected = () => {
if (!data?.liveFinalRoundId) {
toast.error('No LIVE_FINAL round found for this program')
return
}
const ids = Array.from(selected)
if (ids.length === 0) return
enrollMutation.mutate({
programId,
roundId: data.liveFinalRoundId,
enrollments: buildEnrollments(ids),
})
}
const handleEnrollAllEligible = () => {
if (!data?.liveFinalRoundId) {
toast.error('No LIVE_FINAL round found for this program')
return
}
const allCandidates = data.categories.flatMap((c) => c.candidates)
const eligible = allCandidates.filter((c) => c.confirmationStatus !== 'CONFIRMED')
if (eligible.length === 0) {
toast.info('No eligible teams to enroll')
return
}
enrollMutation.mutate({
programId,
roundId: data.liveFinalRoundId,
enrollments: eligible.map((c) => ({
projectId: c.projectId,
mode: 'EMAIL' as const,
})),
})
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
if (isLoading) {
return <Skeleton className="h-56 w-full rounded-md" />
}
const noMentoringTeams = !data || data.categories.length === 0
return (
<>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<UserCheck className="text-muted-foreground h-4 w-4" />
<CardTitle className="text-base">Enroll finalists</CardTitle>
</div>
<CardDescription>
Select mentoring-round teams to advance into the Grand Final. Each enrolled team
immediately appears on the Finals jury&#39;s project list and receives an attendance
confirmation request (or can be admin-confirmed on the spot).
</CardDescription>
</CardHeader>
<CardContent>
{noMentoringTeams ? (
<p className="text-muted-foreground py-6 text-center text-sm">
No mentoring-round teams to enroll yet.
</p>
) : (
<div className="space-y-6">
{data.categories.map((cat) => (
<div key={cat.category}>
{/* Category header */}
<div className="text-muted-foreground mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide">
<span>{formatEnumLabel(cat.category)}</span>
<span className="font-normal">
{cat.confirmedCount}/{cat.quota ?? '?'} confirmed
{cat.pendingCount > 0 ? `, ${cat.pendingCount} pending` : ''}
</span>
</div>
<div className="space-y-2">
{cat.candidates.map((candidate) => {
const status = deriveStatus(candidate)
const isEnrolled =
status === 'CONFIRMED' || status === 'DECLINED' || status === 'EXPIRED'
const isChecked = selected.has(candidate.projectId)
const rs = rowState[candidate.projectId]
const isUnenrolling =
unenrollMutation.isPending && unenrolling === candidate.projectId
return (
<div
key={candidate.projectId}
className="flex flex-wrap items-start gap-3 rounded-md border p-3"
>
{/* Left: checkbox (or spacer for enrolled rows) */}
<div className="mt-0.5 flex-shrink-0">
{isEnrolled ? (
<div className="h-4 w-4" />
) : (
<Checkbox
checked={isChecked}
onCheckedChange={() => toggleRow(candidate.projectId)}
/>
)}
</div>
{/* Middle: project info */}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{candidate.title}</span>
<StatusBadge candidate={candidate} />
</div>
<div className="text-muted-foreground mt-0.5 text-xs">
{[candidate.teamName, candidate.country]
.filter(Boolean)
.join(' · ') || '—'}
</div>
{/* Mode toggle for selected rows */}
{isChecked && !isEnrolled && (
<div className="mt-2 flex items-center gap-2">
<Button
size="sm"
variant={rs?.mode === 'EMAIL' ? 'default' : 'outline'}
className="h-7 px-2 text-xs"
onClick={() => setMode(candidate.projectId, 'EMAIL', candidate)}
>
Email team
</Button>
<Button
size="sm"
variant={rs?.mode === 'ADMIN_CONFIRM' ? 'default' : 'outline'}
className="h-7 px-2 text-xs"
onClick={() =>
setMode(candidate.projectId, 'ADMIN_CONFIRM', candidate)
}
>
Set attendees now
{rs?.mode === 'ADMIN_CONFIRM' && rs.attendees && (
<span className="ml-1 opacity-70">
({rs.attendees.attendingUserIds.length})
</span>
)}
</Button>
</div>
)}
</div>
{/* Right: un-enroll button for CONFIRMED/DECLINED rows */}
{(status === 'CONFIRMED' || status === 'DECLINED') && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="outline"
disabled={isUnenrolling}
onClick={() => setUnenrolling(candidate.projectId)}
>
{isUnenrolling ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Un-enroll'
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove from Grand Final?</AlertDialogTitle>
<AlertDialogDescription>
This removes <strong>{candidate.title}</strong> from the Grand
Final round and deletes their attendance record. Continue?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setUnenrolling(null)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
unenrollMutation.mutate({
projectId: candidate.projectId,
roundId,
})
}}
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
)
})}
</div>
</div>
))}
{/* Footer actions */}
<div className="flex flex-wrap items-center gap-2 border-t pt-4">
<Button
size="sm"
disabled={selected.size === 0 || enrollMutation.isPending}
onClick={handleEnrollSelected}
>
{enrollMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Enroll selected ({selected.size})
</Button>
<Button
size="sm"
variant="outline"
disabled={enrollMutation.isPending}
onClick={handleEnrollAllEligible}
>
Enroll all eligible
</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* Attendees picker dialog */}
{attendeesDialog && (
<EnrollAttendeesDialog
open={attendeesDialog.open}
onOpenChange={(open) => {
if (!open) {
// If user closes without confirming, revert mode to EMAIL
setRowState((prev) => ({
...prev,
[attendeesDialog.projectId]: {
mode: 'EMAIL',
},
}))
setAttendeesDialog(null)
}
}}
members={attendeesDialog.members}
cap={data?.attendeeCap ?? 3}
initial={rowState[attendeesDialog.projectId]?.attendees}
onConfirm={(ids, flags) =>
handleAttendeesConfirm(attendeesDialog.projectId, ids, flags)
}
/>
)}
</>
)
}

View File

@@ -0,0 +1,79 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { toast } from 'sonner'
import { Eye } from 'lucide-react'
/**
* Admin picker: which previously-submitted documents finale judges see on the
* review page. Default (switch off) shows everything; switching to curated
* mode starts with all slots ticked, and the admin unticks what to hide.
* Grand Final round uploads are always visible regardless.
*/
export function ReviewDocsPicker({ programId, roundId }: { programId: string; roundId: string }) {
const utils = trpc.useUtils()
const { data } = trpc.finalist.getReviewDocSettings.useQuery({ programId, roundId })
const set = trpc.finalist.setReviewVisibleRequirements.useMutation({
onSuccess: () => utils.finalist.getReviewDocSettings.invalidate({ programId, roundId }),
onError: (e) => toast.error(e.message),
})
if (!data || data.options.length === 0) return null
const curated = data.selectedIds !== null
const selected = new Set(data.selectedIds ?? data.options.map((o) => o.requirementId))
const toggleSlot = (id: string, on: boolean) => {
const next = new Set(selected)
if (on) next.add(id)
else next.delete(id)
set.mutate({ roundId, requirementIds: [...next] })
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Eye className="h-5 w-5" /> Documents shown to judges
</CardTitle>
<CardDescription>
Choose which previously submitted documents judges see on the finalist review page.
Documents uploaded directly to this Grand Final round are always visible.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2">
<Switch
id="curate-review-docs"
checked={curated}
disabled={set.isPending}
onCheckedChange={(v) =>
set.mutate({ roundId, requirementIds: v ? data.options.map((o) => o.requirementId) : null })}
/>
<Label htmlFor="curate-review-docs" className="text-sm text-muted-foreground cursor-pointer">
{curated ? 'Curated — judges see only the checked documents' : 'Showing all submitted documents'}
</Label>
</div>
{curated && (
<div className="space-y-2">
{data.options.map((o) => (
<label key={o.requirementId} className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={selected.has(o.requirementId)}
disabled={set.isPending}
onCheckedChange={(v) => toggleSlot(o.requirementId, v === true)}
/>
<span>{o.name} {o.roundName}</span>
<span className="text-xs text-muted-foreground">
({o.fileCount} file{o.fileCount === 1 ? '' : 's'})
</span>
</label>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -1,5 +1,6 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -17,7 +18,14 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { ListOrdered, Loader2 } from 'lucide-react'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ListOrdered, Loader2, PlusCircle } from 'lucide-react'
import { formatEnumLabel } from '@/lib/utils'
import type { CompetitionCategory } from '@prisma/client'
@@ -25,6 +33,145 @@ interface Props {
programId: string
}
function AddToWaitlistForm({ programId }: { programId: string }) {
const utils = trpc.useUtils()
const [category, setCategory] = useState<string>('')
const [projectId, setProjectId] = useState<string>('')
const { data: candidatesData, isLoading: loadingCandidates } =
trpc.finalist.listEnrollmentCandidates.useQuery({ programId })
const { data: waitlistData } = trpc.finalist.listWaitlist.useQuery({ programId })
const addMutation = trpc.finalist.addToWaitlist.useMutation({
onSuccess: () => {
toast.success('Project added to waitlist')
utils.finalist.listWaitlist.invalidate({ programId })
utils.finalist.listEnrollmentCandidates.invalidate({ programId })
setProjectId('')
},
onError: (err) => toast.error(err.message),
})
// Build set of project IDs already on the waitlist
const waitlistedProjectIds = new Set(
(waitlistData ?? [])
.filter((e) => e.status === 'WAITING' || e.status === 'PROMOTED')
.map((e) => e.projectId),
)
// Candidates per selected category — exclude confirmed/waitlisted
const categoryData = candidatesData?.categories.find((c) => c.category === category)
const availableCandidates = (categoryData?.candidates ?? []).filter(
(c) =>
!waitlistedProjectIds.has(c.projectId) &&
c.confirmationStatus !== 'CONFIRMED',
)
// Category options (only categories that have candidates)
const categoryOptions = (candidatesData?.categories ?? []).filter(
(c) =>
c.candidates.some(
(p) =>
!waitlistedProjectIds.has(p.projectId) &&
p.confirmationStatus !== 'CONFIRMED',
),
)
// Derive the next rank for the selected category
const currentMaxRank = Math.max(
0,
...(waitlistData ?? [])
.filter((e) => e.category === category)
.map((e) => e.rank),
)
const nextRank = currentMaxRank + 1
const canSubmit = !!category && !!projectId && !addMutation.isPending
if (loadingCandidates) return <Skeleton className="h-10 w-full" />
return (
<div className="border-t pt-4">
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<PlusCircle className="text-muted-foreground h-4 w-4" />
Add to waitlist
</div>
<div className="flex flex-wrap items-end gap-2">
<div className="min-w-[160px]">
<Select
value={category}
onValueChange={(v) => {
setCategory(v)
setProjectId('')
}}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
{categoryOptions.length === 0 ? (
<SelectItem value="__none__" disabled>
No eligible categories
</SelectItem>
) : (
categoryOptions.map((c) => (
<SelectItem key={c.category} value={c.category}>
{formatEnumLabel(c.category)}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<div className="min-w-[220px] flex-1">
<Select
value={projectId}
onValueChange={setProjectId}
disabled={!category || availableCandidates.length === 0}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder={category ? 'Select project' : 'Choose category first'} />
</SelectTrigger>
<SelectContent>
{availableCandidates.length === 0 ? (
<SelectItem value="__none__" disabled>
No eligible projects
</SelectItem>
) : (
availableCandidates.map((c) => (
<SelectItem key={c.projectId} value={c.projectId}>
{c.title}
{c.country ? ` · ${c.country}` : ''}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<Button
size="sm"
disabled={!canSubmit}
onClick={() =>
addMutation.mutate({
programId,
category: category as CompetitionCategory,
projectId,
rank: nextRank,
})
}
>
{addMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Add at end'
)}
</Button>
</div>
</div>
)
}
const STATUS_LABEL: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' | 'destructive' }> = {
WAITING: { label: 'Waiting', variant: 'outline' },
PROMOTED: { label: 'Promoted', variant: 'default' },
@@ -65,10 +212,11 @@ export function WaitlistCard({ programId }: Props) {
Per-category ranked waitlist. Auto-cascades when a finalist declines or expires.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground py-6 text-center text-sm">
<CardContent className="space-y-4">
<p className="text-muted-foreground py-4 text-center text-sm">
No waitlist entries yet.
</p>
<AddToWaitlistForm programId={programId} />
</CardContent>
</Card>
)
@@ -161,6 +309,7 @@ export function WaitlistCard({ programId }: Props) {
</div>
</div>
))}
<AddToWaitlistForm programId={programId} />
</CardContent>
</Card>
)

View File

@@ -0,0 +1,278 @@
'use client'
import { useEffect, useState } from 'react'
import { QRCodeSVG } from 'qrcode.react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { formatClock } from '@/lib/live-timer'
import { ChevronDown, QrCode, Users, Vote, XCircle } from 'lucide-react'
import { toast } from 'sonner'
const WINDOW_LABEL: Record<string, string> = {
'CATEGORY:BUSINESS_CONCEPT': 'Business Concepts',
'CATEGORY:STARTUP': 'Startups',
OVERALL: 'Overall favorite',
}
/**
* Audience favorite-vote control: open a per-category (or overall) window for
* N minutes, watch the live vote count, close early, and project the QR code.
*/
export function AudienceWindowPanel({ roundId }: { roundId: string }) {
const utils = trpc.useUtils()
const { data: session } = trpc.liveVoting.getSession.useQuery(
{ roundId },
{ refetchInterval: 3000 }
)
const { data: tallies } = trpc.liveVoting.getFavoriteTallies.useQuery(
{ sessionId: session?.id ?? '' },
{ enabled: !!session?.id, refetchInterval: 3000 }
)
const [durationMin, setDurationMin] = useState('5')
const [talliesOpen, setTalliesOpen] = useState(false)
const [, tick] = useState(0)
useEffect(() => {
const id = setInterval(() => tick((t) => t + 1), 1000)
return () => clearInterval(id)
}, [])
const invalidate = () => {
utils.liveVoting.getSession.invalidate({ roundId })
if (session?.id) utils.liveVoting.getFavoriteTallies.invalidate({ sessionId: session.id })
}
const onError = (err: { message: string }) => toast.error(err.message)
const openWindow = trpc.liveVoting.openAudienceWindow.useMutation({
onSuccess: invalidate,
onError,
})
const closeWindow = trpc.liveVoting.closeAudienceWindow.useMutation({
onSuccess: () => {
invalidate()
toast.success('Audience voting closed')
},
onError,
})
const updateConfig = trpc.liveVoting.updateSessionConfig.useMutation({
onSuccess: invalidate,
onError,
})
if (!session) return null
const closesAt = session.audienceWindowClosesAt ? new Date(session.audienceWindowClosesAt) : null
const secondsLeft = closesAt ? Math.floor((closesAt.getTime() - Date.now()) / 1000) : null
const isOpen = session.audiencePhase === 'OPEN' && secondsLeft !== null && secondsLeft > 0
const openKey = isOpen ? session.audienceWindowKey : null
const currentWindow = tallies?.windows.find((w) => w.windowKey === openKey)
const voteUrl =
typeof window !== 'undefined' ? `${window.location.origin}/vote/competition/${roundId}` : ''
const duration = Math.max(1, parseInt(durationMin, 10) || 5)
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Vote className="h-5 w-5" />
Audience Vote
</CardTitle>
<CardDescription>
{session.allowAudienceVotes
? 'Favorite-pick windows, one vote per phone per window'
: 'Audience voting is disabled in session config'}
</CardDescription>
</div>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<QrCode className="mr-2 h-4 w-4" />
Show QR
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="text-center">Scan to vote</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center gap-4 py-4">
<div className="rounded-3xl bg-white p-6 shadow-lg">
{voteUrl && <QRCodeSVG value={voteUrl} size={420} />}
</div>
<p className="select-all break-all text-center text-sm text-muted-foreground">
{voteUrl}
</p>
</div>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent className="space-y-4">
{isOpen ? (
<div className="space-y-3 rounded-lg border border-[#de0f1e]/30 bg-[#de0f1e]/5 p-4">
<div className="flex items-center justify-between">
<Badge className="bg-[#de0f1e] hover:bg-[#de0f1e]">
OPEN {WINDOW_LABEL[openKey ?? ''] ?? openKey}
</Badge>
<span className="text-2xl font-bold tabular-nums">
{formatClock(Math.max(0, secondsLeft ?? 0))}
</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="h-4 w-4" />
<span className="font-semibold text-foreground">
{currentWindow?.totalVotes ?? 0}
</span>
votes cast
</div>
<Button
variant="destructive"
className="w-full"
onClick={() => closeWindow.mutate({ sessionId: session.id })}
disabled={closeWindow.isPending}
>
<XCircle className="mr-2 h-4 w-4" />
Close voting now
</Button>
</div>
) : (
<div className="space-y-3">
<div className="flex items-end gap-3">
<div className="space-y-1">
<Label className="text-xs">Duration (min)</Label>
<Input
type="number"
min="1"
max="120"
value={durationMin}
onChange={(e) => setDurationMin(e.target.value)}
className="w-24"
/>
</div>
<p className="pb-2 text-xs text-muted-foreground">
Voting closes automatically server-enforced
</p>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<Button
variant="outline"
disabled={openWindow.isPending || !session.allowAudienceVotes}
onClick={() =>
openWindow.mutate({
sessionId: session.id,
windowKey: 'CATEGORY:BUSINESS_CONCEPT',
durationMinutes: duration,
})
}
>
Open vote Business Concepts
</Button>
<Button
variant="outline"
disabled={openWindow.isPending || !session.allowAudienceVotes}
onClick={() =>
openWindow.mutate({
sessionId: session.id,
windowKey: 'CATEGORY:STARTUP',
durationMinutes: duration,
})
}
>
Open vote Startups
</Button>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<p className="text-sm font-medium">Overall favorite (across both categories)</p>
<p className="text-xs text-muted-foreground">Decide day-of off by default</p>
</div>
<div className="flex items-center gap-3">
<Switch
checked={!!session.allowOverallFavorite}
onCheckedChange={(checked) =>
updateConfig.mutate({ sessionId: session.id, allowOverallFavorite: checked })
}
/>
<Button
variant="outline"
size="sm"
disabled={
openWindow.isPending || !session.allowOverallFavorite || !session.allowAudienceVotes
}
onClick={() =>
openWindow.mutate({
sessionId: session.id,
windowKey: 'OVERALL',
durationMinutes: duration,
})
}
>
Open
</Button>
</div>
</div>
{!session.allowAudienceVotes && (
<Button
variant="secondary"
size="sm"
className="w-full"
onClick={() =>
updateConfig.mutate({ sessionId: session.id, allowAudienceVotes: true })
}
disabled={updateConfig.isPending}
>
Enable audience voting for this session
</Button>
)}
</div>
)}
{/* Tallies — admin eyes only */}
{tallies && tallies.windows.length > 0 && (
<Collapsible open={talliesOpen} onOpenChange={setTalliesOpen}>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="w-full justify-between">
Tallies (admin only)
<ChevronDown
className={`h-4 w-4 transition-transform ${talliesOpen ? 'rotate-180' : ''}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-2">
{tallies.windows.map((w) => (
<div key={w.windowKey} className="rounded-lg border p-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold">
{WINDOW_LABEL[w.windowKey] ?? w.windowKey}
</p>
<span className="text-xs text-muted-foreground">{w.totalVotes} votes</span>
</div>
<div className="space-y-1">
{w.projects.map((p) => (
<div key={p.projectId} className="flex items-center justify-between text-sm">
<span className="truncate">{p.teamName ?? p.title}</span>
<span className="font-semibold tabular-nums">{p.count}</span>
</div>
))}
</div>
</div>
))}
</CollapsibleContent>
</Collapsible>
)}
</CardContent>
</Card>
)
}

View File

@@ -1,238 +1,159 @@
'use client';
'use client'
import { useState, useEffect } from 'react';
import { trpc } from '@/lib/trpc/client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ChevronLeft, ChevronRight, Play, Square, Pause, Timer } from 'lucide-react';
import { toast } from 'sonner';
import { useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { PhaseControls } from './phase-controls'
import { RunOrderList } from './run-order-list'
import { AudienceWindowPanel } from './audience-window-panel'
import { TimingLogCard } from './timing-log-card'
import { RevealPanel } from './reveal-panel'
import { Coffee, ExternalLink, Hand, MonitorPlay, PartyPopper, Play, Scale, X } from 'lucide-react'
import { toast } from 'sonner'
interface LiveControlPanelProps {
roundId: string;
competitionId: string;
roundId: string
competitionId: string
}
const OVERRIDE_SLIDES = [
{ value: 'welcome', label: 'Welcome', icon: Hand },
{ value: 'break', label: 'Break', icon: Coffee },
{ value: 'deliberation', label: 'Deliberation', icon: Scale },
{ value: 'thanks', label: 'Thank you', icon: PartyPopper },
] as const
/**
* Grand-finale ceremony console. Everything an admin touches during the
* event lives here: run order, phase driver with real timers, audience vote
* windows + QR, big-screen override slides, timing log, and the results
* reveal stepper.
*/
export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelProps) {
const utils = trpc.useUtils();
const [timerSeconds, setTimerSeconds] = useState(300);
const [isTimerRunning, setIsTimerRunning] = useState(false);
const { data: cursor } = trpc.live.getCursor.useQuery(
const utils = trpc.useUtils()
const { data: cursor, isLoading } = trpc.live.getCursor.useQuery(
{ roundId },
{ refetchInterval: 5000 }
);
{ refetchInterval: 2000 }
)
const { data: projectStates } = trpc.roundEngine.getProjectStates.useQuery(
{ roundId },
{ enabled: !cursor && !isLoading }
)
const [starting, setStarting] = useState(false)
const jumpMutation = trpc.live.jump.useMutation({
const startMutation = trpc.live.start.useMutation({
onSuccess: () => {
utils.live.getCursor.invalidate({ roundId });
utils.live.getCursor.invalidate({ roundId })
toast.success('Ceremony session started')
},
onError: (err) => toast.error(err.message),
});
const pauseMutation = trpc.live.pause.useMutation({
onSuccess: () => {
utils.live.getCursor.invalidate({ roundId });
toast.success('Live session paused');
},
onSettled: () => setStarting(false),
})
const overrideMutation = trpc.live.setOverrideSlide.useMutation({
onSuccess: () => utils.live.getCursor.invalidate({ roundId }),
onError: (err) => toast.error(err.message),
});
})
const resumeMutation = trpc.live.resume.useMutation({
onSuccess: () => {
utils.live.getCursor.invalidate({ roundId });
toast.success('Live session resumed');
},
onError: (err) => toast.error(err.message),
});
useEffect(() => {
if (!isTimerRunning) return;
const interval = setInterval(() => {
setTimerSeconds((prev) => {
if (prev <= 1) {
setIsTimerRunning(false);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [isTimerRunning]);
const currentIndex = cursor?.activeOrderIndex ?? 0;
const totalProjects = cursor?.totalProjects ?? 0;
const isNavigating = jumpMutation.isPending;
const handlePrevious = () => {
if (currentIndex <= 0) {
toast.info('Already at the first project');
return;
const handleStart = () => {
// Default run order: Business Concepts block first, then Startups
const projects = (projectStates ?? [])
.map((ps: any) => ps.project)
.filter(Boolean)
const order = [
...projects.filter((p: any) => p.competitionCategory === 'BUSINESS_CONCEPT'),
...projects.filter((p: any) => p.competitionCategory === 'STARTUP'),
...projects.filter(
(p: any) => p.competitionCategory !== 'BUSINESS_CONCEPT' && p.competitionCategory !== 'STARTUP'
),
].map((p: any) => p.id)
if (order.length === 0) {
toast.error('No projects in this round yet')
return
}
jumpMutation.mutate({ roundId, index: currentIndex - 1 });
};
setStarting(true)
startMutation.mutate({ roundId, projectOrder: order })
}
const handleNext = () => {
if (currentIndex >= totalProjects - 1) {
toast.info('Already at the last project');
return;
}
jumpMutation.mutate({ roundId, index: currentIndex + 1 });
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
return (
<div className="space-y-6">
{/* Current Project Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Current Project</CardTitle>
<div className="flex items-center gap-2">
{cursor && (
<span className="text-sm text-muted-foreground tabular-nums">
{currentIndex + 1} / {totalProjects}
</span>
)}
<Button
variant="outline"
size="icon"
onClick={handlePrevious}
disabled={isNavigating || currentIndex <= 0}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={handleNext}
disabled={isNavigating || currentIndex >= totalProjects - 1}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{cursor?.activeProject ? (
<div className="space-y-4">
<div>
<h3 className="text-2xl font-bold">{cursor.activeProject.title}</h3>
{cursor.activeProject.teamName && (
<p className="text-muted-foreground">{cursor.activeProject.teamName}</p>
)}
</div>
{cursor.activeProject.tags && (cursor.activeProject.tags as string[]).length > 0 && (
<div className="flex flex-wrap gap-1">
{(cursor.activeProject.tags as string[]).map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
</div>
) : (
<p className="text-muted-foreground">
{cursor ? 'No project selected' : 'No live session active for this round'}
</p>
)}
</CardContent>
</Card>
{/* Timer Card */}
// ── Not started yet ───────────────────────────────────────────────────────
if (!cursor) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Timer className="h-5 w-5" />
Timer
<MonitorPlay className="h-5 w-5" />
Ceremony Console
</CardTitle>
<CardDescription>
Start the live session when the event begins it creates the presentation cursor
every screen follows. The set start time is indicative; nothing moves until you click.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-center">
<div className="text-5xl font-bold tabular-nums">{formatTime(timerSeconds)}</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
{!isTimerRunning ? (
<CardContent className="space-y-3">
<Button size="lg" className="w-full" onClick={handleStart} disabled={isLoading || starting}>
<Play className="mr-2 h-4 w-4" />
{starting ? 'Starting…' : 'Start ceremony session'}
</Button>
<p className="text-center text-xs text-muted-foreground">
Run order defaults to Business Concepts Startups; reorder anytime after starting.
</p>
</CardContent>
</Card>
)
}
return (
<div className="space-y-4">
{/* Big-screen quick bar */}
<Card>
<CardContent className="flex flex-wrap items-center gap-2 py-3">
<span className="mr-1 text-sm font-medium">Big screen:</span>
{OVERRIDE_SLIDES.map((slide) => {
const active = cursor.overrideSlide === slide.value
const SlideIcon = slide.icon
return (
<Button
className="flex-1"
onClick={() => setIsTimerRunning(true)}
disabled={timerSeconds === 0}
key={slide.value}
variant={active ? 'default' : 'outline'}
size="sm"
onClick={() =>
overrideMutation.mutate({ roundId, slide: active ? null : slide.value })
}
disabled={overrideMutation.isPending}
>
<Play className="mr-2 h-4 w-4" />
Start Timer
<SlideIcon className="mr-1.5 h-3.5 w-3.5" />
{slide.label}
{active && <X className="ml-1.5 h-3 w-3" />}
</Button>
) : (
<Button className="flex-1" onClick={() => setIsTimerRunning(false)} variant="destructive">
<Square className="mr-2 h-4 w-4" />
Stop Timer
</Button>
)}
<Button
variant="outline"
onClick={() => {
setTimerSeconds(300);
setIsTimerRunning(false);
}}
>
Reset (5:00)
</Button>
</div>
)
})}
{cursor.overrideSlide && (
<Badge variant="destructive" className="ml-auto">
Override active live content hidden
</Badge>
)}
<Button asChild variant="ghost" size="sm" className={cursor.overrideSlide ? '' : 'ml-auto'}>
<Link href={`/live/ceremony/${roundId}`} target="_blank">
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
Open big screen
</Link>
</Button>
</CardContent>
</Card>
{/* Session Controls */}
<Card>
<CardHeader>
<CardTitle>Session Controls</CardTitle>
<CardDescription>Pause or resume the live presentation</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{cursor?.isPaused ? (
<Button
className="w-full"
onClick={() => resumeMutation.mutate({ roundId })}
disabled={resumeMutation.isPending}
>
<Play className="mr-2 h-4 w-4" />
{resumeMutation.isPending ? 'Resuming...' : 'Resume Session'}
</Button>
) : (
<Button
className="w-full"
variant="outline"
onClick={() => pauseMutation.mutate({ roundId })}
disabled={pauseMutation.isPending || !cursor}
>
<Pause className="mr-2 h-4 w-4" />
{pauseMutation.isPending ? 'Pausing...' : 'Pause Session'}
</Button>
)}
{cursor?.isPaused && (
<Badge variant="destructive" className="w-full justify-center py-1">
Session Paused
</Badge>
)}
{cursor?.openCohorts && cursor.openCohorts.length > 0 && (
<div className="rounded-lg border p-3">
<p className="text-sm font-medium mb-2">Open Voting Windows</p>
{cursor.openCohorts.map((cohort: any) => (
<div key={cohort.id} className="flex items-center justify-between text-sm">
<span>{cohort.name}</span>
<Badge variant="outline">{cohort.votingMode}</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-4">
<PhaseControls roundId={roundId} />
<RunOrderList roundId={roundId} />
</div>
<div className="space-y-4">
<AudienceWindowPanel roundId={roundId} />
<RevealPanel roundId={roundId} competitionId={competitionId} />
<TimingLogCard roundId={roundId} />
</div>
</div>
</div>
);
)
}

View File

@@ -0,0 +1,222 @@
'use client'
import { useEffect, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { remainingSeconds, formatClock, parseClock } from '@/lib/live-timer'
import {
Mic2,
MessageCircleQuestion,
PenLine,
Pause,
Play,
SkipForward,
MonitorUp,
} from 'lucide-react'
import { toast } from 'sonner'
const PHASE_LABEL: Record<string, string> = {
ON_DECK: 'On deck',
PRESENTING: 'Presenting',
QA: 'Q&A',
SCORING: 'Scoring',
}
/**
* The ceremony driver: one primary button for the next phase transition, a
* server-derived countdown that goes red past zero, pause/resume, and
* per-run duration overrides.
*/
export function PhaseControls({ roundId }: { roundId: string }) {
const utils = trpc.useUtils()
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId }, { refetchInterval: 2000 })
const [presentationMin, setPresentationMin] = useState<string>('')
const [qaMin, setQaMin] = useState<string>('')
const [, tick] = useState(0)
useEffect(() => {
const id = setInterval(() => tick((t) => t + 1), 1000)
return () => clearInterval(id)
}, [])
const invalidate = () => utils.live.getCursor.invalidate({ roundId })
const onError = (err: { message: string }) => toast.error(err.message)
const startPresentation = trpc.live.startPresentation.useMutation({ onSuccess: invalidate, onError })
const startQA = trpc.live.startQA.useMutation({ onSuccess: invalidate, onError })
const openScoring = trpc.live.openScoring.useMutation({ onSuccess: invalidate, onError })
const sendToScreens = trpc.live.sendToScreens.useMutation({ onSuccess: invalidate, onError })
const pausePhase = trpc.live.pausePhase.useMutation({ onSuccess: invalidate, onError })
const resumePhase = trpc.live.resumePhase.useMutation({ onSuccess: invalidate, onError })
if (!cursor) {
return null
}
const phase = cursor.projectPhase
const remaining = remainingSeconds(cursor)
const over = remaining !== null && remaining < 0
const paused = !!cursor.phasePausedAt
const busy =
startPresentation.isPending ||
startQA.isPending ||
openScoring.isPending ||
sendToScreens.isPending
const durationSeconds = (raw: string) => parseClock(raw) ?? undefined
const nextProject = (() => {
const order = cursor.orderedProjects ?? []
const idx = order.findIndex((p) => p.id === cursor.activeProjectId)
return idx >= 0 && idx < order.length - 1 ? order[idx + 1] : null
})()
const primaryAction = (() => {
switch (phase) {
case 'ON_DECK':
return {
label: 'Start presentation',
icon: Mic2,
run: () =>
startPresentation.mutate({
roundId,
durationSeconds: durationSeconds(presentationMin),
}),
disabled: !cursor.activeProjectId,
}
case 'PRESENTING':
return {
label: 'Start Q&A',
icon: MessageCircleQuestion,
run: () => startQA.mutate({ roundId, durationSeconds: durationSeconds(qaMin) }),
disabled: false,
}
case 'QA':
return {
label: 'Open scoring',
icon: PenLine,
run: () => openScoring.mutate({ roundId }),
disabled: false,
}
case 'SCORING':
default:
return nextProject
? {
label: `Send next: ${nextProject.teamName ?? nextProject.title}`,
icon: MonitorUp,
run: () => sendToScreens.mutate({ roundId, projectId: nextProject.id }),
disabled: false,
}
: {
label: 'End of run order',
icon: SkipForward,
run: () => undefined,
disabled: true,
}
}
})()
const PrimaryIcon = primaryAction.icon
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Ceremony Control</CardTitle>
<CardDescription>
{cursor.activeProject
? `${cursor.activeProject.title}${cursor.activeProject.teamName ? `${cursor.activeProject.teamName}` : ''}`
: 'No project on screens yet'}
</CardDescription>
</div>
<Badge variant={phase === 'SCORING' ? 'default' : 'secondary'}>
{PHASE_LABEL[phase] ?? phase}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-5">
{/* Server-derived countdown */}
<div className="text-center">
<div
className={`text-6xl font-bold tabular-nums ${
over ? 'animate-pulse text-[#de0f1e]' : remaining === null ? 'text-muted-foreground/40' : ''
}`}
>
{remaining === null ? ':' : formatClock(remaining)}
</div>
<p className="mt-1 text-xs text-muted-foreground">
{remaining === null
? 'No timer running'
: over
? `Over time${paused ? ' · paused' : ''} — noted, not penalized`
: paused
? 'Paused'
: phase === 'PRESENTING'
? 'Presentation time remaining'
: 'Q&A time remaining'}
</p>
</div>
{/* Primary transition + pause */}
<div className="flex gap-2">
<Button
className="flex-1"
size="lg"
onClick={primaryAction.run}
disabled={primaryAction.disabled || busy}
>
<PrimaryIcon className="mr-2 h-4 w-4" />
{primaryAction.label}
</Button>
{remaining !== null &&
(paused ? (
<Button
variant="outline"
size="lg"
onClick={() => resumePhase.mutate({ roundId })}
disabled={resumePhase.isPending}
>
<Play className="mr-2 h-4 w-4" />
Resume
</Button>
) : (
<Button
variant="outline"
size="lg"
onClick={() => pausePhase.mutate({ roundId })}
disabled={pausePhase.isPending}
>
<Pause className="mr-2 h-4 w-4" />
Pause
</Button>
))}
</div>
{/* One-off duration overrides for the NEXT start only (m:ss).
Per-project durations live in the Run Order list. */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Presentation override (m:ss, next start only)</Label>
<Input
placeholder="e.g. 7:30"
className="tabular-nums"
value={presentationMin}
onChange={(e) => setPresentationMin(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Q&A override (m:ss, next start only)</Label>
<Input
placeholder="e.g. 2:00"
className="tabular-nums"
value={qaMin}
onChange={(e) => setQaMin(e.target.value)}
/>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,342 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { ArrowDown, ArrowUp, PartyPopper, Play, RotateCcw, Sparkles, Trash2, Wand2 } from 'lucide-react'
import { toast } from 'sonner'
type RevealStep = {
kind: 'category-intro' | 'place' | 'audience-award' | 'overall-favorite' | 'thanks'
category?: 'STARTUP' | 'BUSINESS_CONCEPT'
place?: number
projectId?: string
title?: string
subtitle?: string
}
const CATEGORY_LABEL: Record<string, string> = {
BUSINESS_CONCEPT: 'Business Concepts',
STARTUP: 'Startups',
}
const PLACE_LABEL: Record<number, string> = { 1: 'Winner', 2: '2nd place', 3: '3rd place' }
function describeStep(step: RevealStep): string {
switch (step.kind) {
case 'category-intro':
return `${CATEGORY_LABEL[step.category ?? ''] ?? 'Category'}`
case 'place':
return `${PLACE_LABEL[step.place ?? 0] ?? `${step.place}th`} · ${step.title ?? '?'} (${CATEGORY_LABEL[step.category ?? ''] ?? ''})`
case 'audience-award':
return `Audience Choice (${CATEGORY_LABEL[step.category ?? ''] ?? ''}) · ${step.title ?? '?'}`
case 'overall-favorite':
return `Audience Favorite Overall · ${step.title ?? '?'}`
case 'thanks':
return 'Thank-you slide'
}
}
/**
* Results reveal builder + stepper. Compose privately from deliberation
* results / jury scores / audience tallies, preview every step, then arm the
* big screen and fire one step at a time. Nothing reaches the projector
* before "Arm".
*/
export function RevealPanel({ roundId, competitionId }: { roundId: string; competitionId: string }) {
const utils = trpc.useUtils()
const { data: session } = trpc.liveVoting.getSession.useQuery({ roundId })
const sessionId = session?.id ?? ''
const { data: reveal } = trpc.liveVoting.getRevealAdmin.useQuery(
{ sessionId },
{ enabled: !!sessionId, refetchInterval: 3000 }
)
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId })
const { data: results } = trpc.liveVoting.getResults.useQuery(
{ sessionId },
{ enabled: !!sessionId }
)
const { data: tallies } = trpc.liveVoting.getFavoriteTallies.useQuery(
{ sessionId },
{ enabled: !!sessionId }
)
const { data: delibSessions } = trpc.deliberation.listSessions.useQuery({ competitionId })
const [draftSteps, setDraftSteps] = useState<RevealStep[] | null>(null)
const invalidate = () => utils.liveVoting.getRevealAdmin.invalidate({ sessionId })
const onError = (err: { message: string }) => toast.error(err.message)
const saveReveal = trpc.liveVoting.saveReveal.useMutation({
onSuccess: () => {
setDraftSteps(null) // the saved copy is now canonical — unlocks Arm
invalidate()
toast.success('Reveal draft saved')
},
onError,
})
const armReveal = trpc.liveVoting.armReveal.useMutation({ onSuccess: invalidate, onError })
const revealNext = trpc.liveVoting.revealNext.useMutation({ onSuccess: invalidate, onError })
const resetReveal = trpc.liveVoting.resetReveal.useMutation({ onSuccess: invalidate, onError })
if (!session) return null
const savedSteps = (reveal?.stepsJson as RevealStep[] | undefined) ?? []
const steps = draftSteps ?? savedSteps
const status = reveal?.status ?? 'DRAFT'
const currentIndex = reveal?.currentStepIndex ?? -1
const categoryOf = (projectId: string) =>
cursor?.orderedProjects?.find((p) => p.id === projectId)?.competitionCategory ?? null
const displayName = (projectId: string) => {
const p = cursor?.orderedProjects?.find((p) => p.id === projectId)
return p?.teamName ?? p?.title ?? 'Unknown'
}
const compose = () => {
const composed: RevealStep[] = []
const categories: Array<'BUSINESS_CONCEPT' | 'STARTUP'> = ['BUSINESS_CONCEPT', 'STARTUP']
let usedDeliberation = false
for (const category of categories) {
// Locked deliberation results take precedence; jury score order is the fallback
const delib = (delibSessions ?? []).find(
(s: any) => s.category === category && s.status === 'DELIB_LOCKED' && s.results?.length > 0
)
let rankedProjectIds: string[]
if (delib) {
rankedProjectIds = delib.results.map((r: any) => r.projectId)
usedDeliberation = true
} else {
rankedProjectIds = (results?.results ?? [])
.filter((r: any) => r.project?.id && categoryOf(r.project.id) === category)
.map((r: any) => r.project.id)
}
if (rankedProjectIds.length === 0) continue
composed.push({
kind: 'category-intro',
category,
title: CATEGORY_LABEL[category],
})
const top = rankedProjectIds.slice(0, 3)
// Reverse order: 3rd → 2nd → 1st
top
.map((projectId, idx) => ({ projectId, place: idx + 1 }))
.reverse()
.forEach(({ projectId, place }) => {
composed.push({
kind: 'place',
category,
place,
projectId,
title: displayName(projectId),
subtitle: `${PLACE_LABEL[place] ?? `${place}th place`}${CATEGORY_LABEL[category]}`,
})
})
const audienceWindow = tallies?.windows.find((w) => w.windowKey === `CATEGORY:${category}`)
const audienceTop = audienceWindow?.projects[0]
if (audienceTop) {
composed.push({
kind: 'audience-award',
category,
projectId: audienceTop.projectId,
title: audienceTop.teamName ?? audienceTop.title,
subtitle: `Audience Choice — ${CATEGORY_LABEL[category]}`,
})
}
}
const overallWindow = tallies?.windows.find((w) => w.windowKey === 'OVERALL')
const overallTop = overallWindow?.projects[0]
if (overallTop) {
composed.push({
kind: 'overall-favorite',
projectId: overallTop.projectId,
title: overallTop.teamName ?? overallTop.title,
subtitle: 'Audience Favorite — Overall',
})
}
composed.push({ kind: 'thanks', title: 'Thank you' })
if (composed.length <= 1) {
toast.info('No results to compose from yet — scores and votes are still empty')
return
}
toast.success(
usedDeliberation
? 'Composed from locked deliberation results + audience tallies'
: 'Composed from jury scores + audience tallies (no locked deliberation yet)'
)
setDraftSteps(composed)
}
const moveStep = (index: number, delta: -1 | 1) => {
const next = [...steps]
const target = index + delta
if (target < 0 || target >= next.length) return
;[next[index], next[target]] = [next[target], next[index]]
setDraftSteps(next)
}
const removeStep = (index: number) => {
setDraftSteps(steps.filter((_, i) => i !== index))
}
const isDraft = status === 'DRAFT'
const isLive = status === 'REVEALING' || status === 'DONE'
const nextStep = steps[currentIndex + 1]
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<PartyPopper className="h-5 w-5" />
Results Reveal
</CardTitle>
<CardDescription>
Compose privately, arm the big screen, reveal step by step
</CardDescription>
</div>
<Badge
variant={isDraft ? 'secondary' : 'default'}
className={isLive ? 'bg-[#de0f1e] hover:bg-[#de0f1e]' : undefined}
>
{status}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{isDraft && (
<>
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={compose}>
<Wand2 className="mr-2 h-4 w-4" />
Compose from results
</Button>
<Button
className="flex-1"
disabled={!draftSteps || saveReveal.isPending}
onClick={() => sessionId && saveReveal.mutate({ sessionId, steps })}
>
Save draft
</Button>
</div>
<p className="text-xs text-muted-foreground">
Locked deliberation results take precedence; otherwise jury scores (top 3 per
category, revealed 3rd 1st), plus audience tallies. Adjust the steps below
before saving if needed.
</p>
</>
)}
{steps.length > 0 && (
<div className="space-y-1">
{steps.map((step, i) => {
const revealed = i <= currentIndex
return (
<div
key={i}
className={`flex items-center gap-2 rounded-lg border p-2 text-sm ${
revealed
? 'border-green-600/30 bg-green-600/5'
: i === currentIndex + 1 && !isDraft
? 'border-[#de0f1e]/40 bg-[#de0f1e]/5'
: ''
}`}
>
<span className="w-5 text-center text-xs tabular-nums text-muted-foreground">
{i + 1}
</span>
<span className="min-w-0 flex-1 truncate">{describeStep(step)}</span>
{revealed && <Badge variant="outline" className="text-xs">revealed</Badge>}
{isDraft && (
<div className="flex shrink-0 gap-0.5">
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={i === 0} onClick={() => moveStep(i, -1)}>
<ArrowUp className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={i === steps.length - 1} onClick={() => moveStep(i, 1)}>
<ArrowDown className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => removeStep(i)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
</div>
)
})}
</div>
)}
{isDraft && savedSteps.length > 0 && !draftSteps && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button className="w-full" size="lg">
<Sparkles className="mr-2 h-4 w-4" />
Arm reveal
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Arm the results reveal?</AlertDialogTitle>
<AlertDialogDescription>
The big screen switches to the Results splash immediately. Nothing is revealed
until you press Reveal next.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => sessionId && armReveal.mutate({ sessionId })}>
Arm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{!isDraft && (
<div className="space-y-2">
<Button
className="w-full bg-[#de0f1e] hover:bg-[#c00d1a]"
size="lg"
disabled={revealNext.isPending || status === 'DONE'}
onClick={() => sessionId && revealNext.mutate({ sessionId })}
>
<Play className="mr-2 h-4 w-4" />
{status === 'DONE'
? 'All revealed'
: `Reveal next (${currentIndex + 2}/${steps.length})`}
</Button>
{nextStep && status !== 'DONE' && (
<p className="text-center text-xs text-muted-foreground">
Next on screen: <span className="font-medium">{describeStep(nextStep)}</span>
</p>
)}
<Button
variant="ghost"
size="sm"
className="w-full"
onClick={() => sessionId && resetReveal.mutate({ sessionId })}
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
Reset to draft (leaves reveal mode)
</Button>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,219 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { ArrowDown, ArrowUp, MonitorUp, Timer } from 'lucide-react'
import { formatClock, parseClock } from '@/lib/live-timer'
import { toast } from 'sonner'
const CATEGORY_LABEL: Record<string, string> = {
BUSINESS_CONCEPT: 'Business Concepts',
STARTUP: 'Startups',
}
/**
* The ceremony run order, grouped by category, with quick reorder (▲▼) and a
* "Send to screens" action per project — built for last-minute schedule
* shuffles without leaving the console.
*/
export function RunOrderList({ roundId }: { roundId: string }) {
const utils = trpc.useUtils()
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId }, { refetchInterval: 5000 })
// Local drafts for the per-project minute inputs (committed on blur)
const [timingDrafts, setTimingDrafts] = useState<Record<string, string>>({})
const reorderMutation = trpc.live.reorder.useMutation({
onSuccess: () => utils.live.getCursor.invalidate({ roundId }),
onError: (err) => toast.error(err.message),
})
const timingMutation = trpc.live.setProjectTiming.useMutation({
onSuccess: () => {
utils.live.getCursor.invalidate({ roundId })
toast.success('Project timing saved')
},
onError: (err) => toast.error(err.message),
})
const commitTiming = (projectId: string, field: 'presentationSeconds' | 'qaSeconds', raw: string) => {
const trimmed = raw.trim()
const seconds = trimmed === '' ? null : parseClock(trimmed)
if (trimmed !== '' && seconds === null) {
toast.error('Use minutes:seconds, e.g. 7:30')
return
}
const current = cursor?.projectTimingOverrides?.[projectId]?.[field] ?? null
if (seconds === current) return
timingMutation.mutate({ roundId, projectId, [field]: seconds })
}
const sendMutation = trpc.live.sendToScreens.useMutation({
onSuccess: (_d, vars) => {
utils.live.getCursor.invalidate({ roundId })
const p = cursor?.orderedProjects?.find((p) => p.id === vars.projectId)
toast.success(`${p?.teamName ?? p?.title ?? 'Project'} is now on screens (up next)`)
},
onError: (err) => toast.error(err.message),
})
const projects = cursor?.orderedProjects ?? []
if (!cursor || projects.length === 0) {
return null
}
const move = (index: number, delta: -1 | 1) => {
const order = projects.map((p) => p.id)
const target = index + delta
if (target < 0 || target >= order.length) return
;[order[index], order[target]] = [order[target], order[index]]
reorderMutation.mutate({ roundId, projectOrder: order })
}
// Group rows under category headings while preserving the global order
const rows: Array<{ type: 'heading'; label: string } | { type: 'project'; index: number }> = []
let lastCategory: string | null = null
projects.forEach((p, index) => {
const cat = p.competitionCategory ?? 'OTHER'
if (cat !== lastCategory) {
rows.push({ type: 'heading', label: CATEGORY_LABEL[cat] ?? 'Other' })
lastCategory = cat
}
rows.push({ type: 'project', index })
})
return (
<Card>
<CardHeader>
<CardTitle>Run Order</CardTitle>
<CardDescription>
Reorder presentations on the fly · Send to screens puts a team up next everywhere
</CardDescription>
</CardHeader>
<CardContent className="space-y-1">
{rows.map((row, i) => {
if (row.type === 'heading') {
return (
<p
key={`h-${i}`}
className="pt-3 pb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground first:pt-0"
>
{row.label}
</p>
)
}
const project = projects[row.index]
const isActive = project.id === cursor.activeProjectId
const override = cursor.projectTimingOverrides?.[project.id]
const presKey = `${project.id}:pres`
const qaKey = `${project.id}:qa`
return (
<div
key={project.id}
className={`flex items-center gap-2 rounded-lg border p-2.5 ${
isActive ? 'border-[#de0f1e]/40 bg-[#de0f1e]/5' : 'border-transparent hover:bg-muted/40'
}`}
>
<span className="w-6 text-center text-sm tabular-nums text-muted-foreground">
{row.index + 1}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{project.title}</p>
{project.teamName && (
<p className="truncate text-xs text-muted-foreground">{project.teamName}</p>
)}
{/* Per-project durations (m:ss) — empty = round default */}
<div className="mt-1 flex items-center gap-2">
<Timer className="h-3 w-3 shrink-0 text-muted-foreground" />
<label className="flex items-center gap-1 text-[11px] text-muted-foreground">
Pres
<Input
className="h-6 w-16 px-1.5 text-center text-xs tabular-nums"
placeholder="default"
value={
timingDrafts[presKey] ??
(override?.presentationSeconds != null
? formatClock(override.presentationSeconds)
: '')
}
onChange={(e) =>
setTimingDrafts((d) => ({ ...d, [presKey]: e.target.value }))
}
onBlur={(e) => {
commitTiming(project.id, 'presentationSeconds', e.target.value)
setTimingDrafts((d) => {
const next = { ...d }
delete next[presKey]
return next
})
}}
/>
</label>
<label className="flex items-center gap-1 text-[11px] text-muted-foreground">
Q&A
<Input
className="h-6 w-16 px-1.5 text-center text-xs tabular-nums"
placeholder="default"
value={
timingDrafts[qaKey] ??
(override?.qaSeconds != null ? formatClock(override.qaSeconds) : '')
}
onChange={(e) =>
setTimingDrafts((d) => ({ ...d, [qaKey]: e.target.value }))
}
onBlur={(e) => {
commitTiming(project.id, 'qaSeconds', e.target.value)
setTimingDrafts((d) => {
const next = { ...d }
delete next[qaKey]
return next
})
}}
/>
</label>
<span className="text-[10px] text-muted-foreground/70">m:ss</span>
</div>
</div>
{isActive && (
<Badge className="shrink-0 bg-[#de0f1e] hover:bg-[#de0f1e]">
{cursor.projectPhase === 'ON_DECK' ? 'on deck' : 'live'}
</Badge>
)}
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={reorderMutation.isPending || row.index === 0}
onClick={() => move(row.index, -1)}
>
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={reorderMutation.isPending || row.index === projects.length - 1}
onClick={() => move(row.index, 1)}
>
<ArrowDown className="h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 px-2 text-xs"
disabled={sendMutation.isPending || isActive}
onClick={() => sendMutation.mutate({ roundId, projectId: project.id })}
>
<MonitorUp className="h-3.5 w-3.5" />
Send to screens
</Button>
</div>
</div>
)
})}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,73 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { formatClock } from '@/lib/live-timer'
import { Timer } from 'lucide-react'
type TimingEntry = {
projectId: string
phase: 'PRESENTING' | 'QA'
startedAt: string
endedAt: string
configuredSeconds: number | null
elapsedSeconds: number
overranSeconds: number
}
/**
* Factual per-project timing record: configured vs actual, with overruns
* highlighted (noted, never penalized).
*/
export function TimingLogCard({ roundId }: { roundId: string }) {
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId }, { refetchInterval: 5000 })
const log = (cursor?.timingLogJson as TimingEntry[] | null) ?? []
if (!cursor || log.length === 0) return null
const titleFor = (projectId: string) => {
const p = cursor.orderedProjects?.find((p) => p.id === projectId)
return p?.teamName ?? p?.title ?? 'Unknown'
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Timer className="h-5 w-5" />
Timing Log
</CardTitle>
<CardDescription>Configured vs actual overruns are noted, not penalized</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-1.5">
{log.map((entry, i) => (
<div key={i} className="flex items-center gap-3 rounded-lg border p-2.5 text-sm">
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{titleFor(entry.projectId)}</p>
<p className="text-xs text-muted-foreground">
{entry.phase === 'PRESENTING' ? 'Presentation' : 'Q&A'}
</p>
</div>
<span className="text-xs tabular-nums text-muted-foreground">
{entry.configuredSeconds != null ? formatClock(entry.configuredSeconds) : ''} planned
{' · '}
{formatClock(entry.elapsedSeconds ?? 0)} actual
</span>
{entry.overranSeconds > 0 ? (
<Badge variant="destructive" className="shrink-0 tabular-nums">
+{formatClock(entry.overranSeconds).replace('+', '')} over
</Badge>
) : (
<Badge variant="secondary" className="shrink-0">
on time
</Badge>
)}
</div>
))}
</div>
</CardContent>
</Card>
)
}

View File

@@ -2,6 +2,7 @@
import { useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
@@ -14,6 +15,19 @@ import {
TableRow,
} from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Textarea } from '@/components/ui/textarea'
import { Loader2 } from 'lucide-react'
import { formatEnumLabel } from '@/lib/utils'
import type { FinalistConfirmationStatus } from '@prisma/client'
import { AdminAttendanceDialog, type AttendanceMode } from './admin-attendance-dialog'
@@ -52,17 +66,52 @@ function relativeFromNow(d: Date): string {
}
export function ConfirmationsTab({ programId }: Props) {
const utils = trpc.useUtils()
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [dialogState, setDialogState] = useState<{
open: boolean
mode: AttendanceMode
confirmationId: string | null
}>({ open: false, mode: 'confirm', confirmationId: null })
const [unconfirmState, setUnconfirmState] = useState<{
open: boolean
confirmationId: string | null
projectTitle: string
reason: string
}>({ open: false, confirmationId: null, projectTitle: '', reason: '' })
const { data, isLoading } = trpc.logistics.listConfirmations.useQuery(
{ programId },
{ refetchInterval: 60_000 },
)
// Get liveFinalRoundId for re-invite action
const { data: candidatesData } = trpc.finalist.listEnrollmentCandidates.useQuery({ programId })
const liveFinalRoundId = candidatesData?.liveFinalRoundId ?? null
const unconfirmMutation = trpc.finalist.unconfirm.useMutation({
onSuccess: () => {
toast.success('Finalist un-confirmed')
utils.logistics.listConfirmations.invalidate({ programId })
utils.finalist.listEnrollmentCandidates.invalidate({ programId })
setUnconfirmState((prev) => ({ ...prev, open: false, confirmationId: null, reason: '' }))
},
onError: (err) => toast.error(err.message),
})
const reinviteMutation = trpc.finalist.enrollFinalists.useMutation({
onSuccess: (result) => {
if (result.skipped.length > 0) {
toast.info('Re-invite skipped — team is already confirmed')
} else {
toast.success('Re-invite sent')
}
utils.logistics.listConfirmations.invalidate({ programId })
utils.finalist.listEnrollmentCandidates.invalidate({ programId })
},
onError: (err) => toast.error(err.message),
})
const filtered = useMemo(() => {
if (!data) return []
return statusFilter === 'all' ? data : data.filter((r) => r.status === statusFilter)
@@ -124,7 +173,7 @@ export function ConfirmationsTab({ programId }: Props) {
) : filtered.length === 0 ? (
<p className="text-muted-foreground py-12 text-center text-sm">
{statusFilter === 'all'
? 'No finalists have been selected yet. Use the grand-finale round page to send confirmations.'
? 'No finalists have been selected yet. Enroll finalists from the Grand Final round\'s Overview tab to start confirmations.'
: 'No confirmations match this filter.'}
</p>
) : (
@@ -218,6 +267,43 @@ export function ConfirmationsTab({ programId }: Props) {
Decline
</Button>
</div>
) : r.status === 'CONFIRMED' ? (
<Button
size="sm"
variant="outline"
onClick={() =>
setUnconfirmState({
open: true,
confirmationId: r.id,
projectTitle: r.project.title,
reason: '',
})
}
>
Un-confirm
</Button>
) : r.status === 'DECLINED' || r.status === 'EXPIRED' ? (
<Button
size="sm"
variant="outline"
disabled={!liveFinalRoundId || reinviteMutation.isPending}
onClick={() => {
if (!liveFinalRoundId) return
reinviteMutation.mutate({
programId,
roundId: liveFinalRoundId,
enrollments: [{ projectId: r.project.id, mode: 'EMAIL' }],
})
}}
>
{reinviteMutation.isPending &&
reinviteMutation.variables?.enrollments?.[0]?.projectId ===
r.project.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Re-invite'
)}
</Button>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
@@ -241,6 +327,60 @@ export function ConfirmationsTab({ programId }: Props) {
setDialogState((prev) => ({ ...prev, open: next }))
}
/>
{/* Un-confirm AlertDialog (needs a reason — min 5 chars per the server) */}
<AlertDialog
open={unconfirmState.open}
onOpenChange={(next) => {
if (!unconfirmMutation.isPending)
setUnconfirmState((prev) => ({ ...prev, open: next }))
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Un-confirm this finalist?</AlertDialogTitle>
<AlertDialogDescription>
{unconfirmState.projectTitle} will be moved back to Superseded. Any active mentor
assignment will be dropped and the mentor notified. This action is audit-logged.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="px-1 pb-2">
<label
htmlFor="unconfirm-reason"
className="text-muted-foreground mb-1 block text-sm"
>
Reason <span className="text-destructive">*</span>
</label>
<Textarea
id="unconfirm-reason"
value={unconfirmState.reason}
onChange={(e) =>
setUnconfirmState((prev) => ({ ...prev, reason: e.target.value }))
}
placeholder="e.g. team withdrew, scheduling conflict, administrative correction"
rows={3}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={unconfirmMutation.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={unconfirmState.reason.trim().length < 5 || unconfirmMutation.isPending}
onClick={() => {
if (!unconfirmState.confirmationId) return
unconfirmMutation.mutate({
confirmationId: unconfirmState.confirmationId,
reason: unconfirmState.reason.trim(),
})
}}
>
{unconfirmMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Un-confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -0,0 +1,222 @@
'use client'
import { useState, useRef } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { toast } from 'sonner'
import { Plane, Mail, Eye, Loader2 } from 'lucide-react'
import { EmailPreviewDialog } from '@/components/admin/round/email-preview-dialog'
export function EmailTemplatesTab({ programId }: { programId?: string }) {
const [previewType, setPreviewType] = useState<string | null>(null)
const [testingType, setTestingType] = useState<string | null>(null)
const utils = trpc.useUtils()
const { data: allSettings, isLoading } = trpc.notification.getEmailSettings.useQuery()
const settings = (allSettings ?? []).filter((s) => s.category === 'logistics')
const updateMutation = trpc.notification.updateEmailSetting.useMutation({
onSuccess: () => {
toast.success('Setting updated')
void utils.notification.getEmailSettings.invalidate()
},
onError: (error) => {
toast.error(`Failed to update: ${error.message}`)
},
})
const testMutation = trpc.notification.sendTestEmail.useMutation({
onSuccess: (data) => {
if (data.success) {
toast.success(data.message, {
description: data.hasStyledTemplate ? 'Using styled template' : 'Using generic template',
})
} else {
toast.error('Failed to send test email', { description: data.message })
}
setTestingType(null)
},
onError: (error) => {
toast.error(`Failed to send: ${error.message}`)
setTestingType(null)
},
})
const preview = trpc.notification.previewEmailTemplate.useQuery(
{ notificationType: previewType! },
{ enabled: !!previewType },
)
const previewSetting = settings.find((s) => s.notificationType === previewType)
if (isLoading) {
return (
<div className="space-y-4">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
)
}
if (settings.length === 0) {
return (
<p className="text-sm text-muted-foreground py-8 text-center">
No logistics email types found run the notification settings seed.
</p>
)
}
return (
<>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-3 text-base">
<Plane className="h-5 w-5 text-muted-foreground" />
Logistics Emails
<span className="ml-auto text-xs font-normal text-muted-foreground">
{settings.filter((s) => s.sendEmail).length}/{settings.length} enabled
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{settings.map((setting) => (
<EmailTemplateRow
key={setting.id}
setting={setting}
isTesting={testingType === setting.notificationType}
isUpdating={updateMutation.isPending}
onTest={() => {
setTestingType(setting.notificationType)
testMutation.mutate({ notificationType: setting.notificationType })
}}
onPreview={() => setPreviewType(setting.notificationType)}
onToggle={(checked) =>
updateMutation.mutate({
notificationType: setting.notificationType,
sendEmail: checked,
emailSubject: setting.emailSubject ?? undefined,
})
}
onSubjectBlur={(subject) => {
if (subject !== (setting.emailSubject ?? '')) {
updateMutation.mutate({
notificationType: setting.notificationType,
sendEmail: setting.sendEmail,
emailSubject: subject || undefined,
})
}
}}
/>
))}
</CardContent>
</Card>
<EmailPreviewDialog
open={!!previewType}
onOpenChange={(o) => { if (!o) setPreviewType(null) }}
title={previewSetting?.label ?? 'Email Preview'}
description={previewSetting?.description ?? ''}
recipientCount={0}
previewHtml={preview.data?.html}
isPreviewLoading={preview.isLoading}
onSend={() => {}}
isSending={false}
previewOnly
showCustomMessage={false}
/>
</>
)
}
type RowSetting = {
id: string
notificationType: string
category: string
label: string
description: string | null
sendEmail: boolean
emailSubject: string | null
}
function EmailTemplateRow({
setting,
isTesting,
isUpdating,
onTest,
onPreview,
onToggle,
onSubjectBlur,
}: {
setting: RowSetting
isTesting: boolean
isUpdating: boolean
onTest: () => void
onPreview: () => void
onToggle: (checked: boolean) => void
onSubjectBlur: (value: string) => void
}) {
const inputRef = useRef<HTMLInputElement>(null)
return (
<div className="rounded-lg border p-3 space-y-2">
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5 flex-1 min-w-0">
<Label className="text-sm font-medium">{setting.label}</Label>
{setting.description && (
<p className="text-xs text-muted-foreground">{setting.description}</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-muted-foreground hover:text-foreground"
onClick={onPreview}
title="Preview email"
>
<Eye className="h-4 w-4" />
<span className="ml-1.5 text-xs">Preview</span>
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-muted-foreground hover:text-foreground"
onClick={onTest}
disabled={isTesting}
title="Send test email to yourself"
>
{isTesting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Mail className="h-4 w-4" />
)}
<span className="ml-1.5 text-xs">Test</span>
</Button>
<Switch
checked={setting.sendEmail}
onCheckedChange={onToggle}
disabled={isUpdating}
/>
</div>
</div>
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground w-16 shrink-0">Subject</Label>
<Input
ref={inputRef}
className="h-7 text-xs"
defaultValue={setting.emailSubject ?? ''}
placeholder="(default subject)"
onBlur={(e) => onSubjectBlur(e.target.value)}
/>
</div>
</div>
)
}

View File

@@ -1,175 +1,700 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Skeleton } from '@/components/ui/skeleton'
import { ExternalLink, Hotel as HotelIcon, Loader2, Save } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Download, ExternalLink, Hotel as HotelIcon, Loader2, Plus, Trash2, Pencil } from 'lucide-react'
// Radix <SelectItem> forbids an empty-string value, so the "unassigned" option
// uses this sentinel; handleHotelChange maps it back to an unassign.
const UNASSIGN_VALUE = '__unassign__'
interface Props {
programId: string
}
export function HotelsTab({ programId }: Props) {
const utils = trpc.useUtils()
const { data: hotel, isLoading } = trpc.logistics.getHotel.useQuery({ programId })
// ─── Types ────────────────────────────────────────────────────────────────────
type HotelRow = {
id: string
name: string
address: string | null
link: string | null
notes: string | null
_count: { stays: number }
}
type RoomingRow = {
attendingMemberId: string
confirmationId: string
projectId: string
projectTitle: string
user: { id: string; name: string | null; email: string }
stay: { hotelId: string; roomNumber: string | null; checkInAt: Date | null; checkOutAt: Date | null } | null
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function toDateInputValue(d: Date | null | undefined): string {
if (!d) return ''
const dt = new Date(d)
if (Number.isNaN(dt.getTime())) return ''
return dt.toISOString().slice(0, 10)
}
function fromDateInputValue(s: string): Date | null {
if (!s) return null
const dt = new Date(s)
return Number.isNaN(dt.getTime()) ? null : dt
}
function csvEscape(value: string | null | undefined): string {
const str = value ?? ''
if (str.includes('"') || str.includes(',') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
function buildRoomingCsv(rows: RoomingRow[], hotels: HotelRow[]): string {
const hotelMap = new Map(hotels.map((h) => [h.id, h.name]))
const header = ['Team', 'Member', 'Email', 'Hotel', 'Room', 'Check-in', 'Check-out'].join(',')
const lines = rows.map((r) => {
const s = r.stay
return [
csvEscape(r.projectTitle),
csvEscape(r.user.name ?? r.user.email),
csvEscape(r.user.email),
csvEscape(s ? (hotelMap.get(s.hotelId) ?? '') : ''),
csvEscape(s?.roomNumber ?? ''),
csvEscape(s?.checkInAt ? toDateInputValue(s.checkInAt) : ''),
csvEscape(s?.checkOutAt ? toDateInputValue(s.checkOutAt) : ''),
].join(',')
})
return [header, ...lines].join('\r\n')
}
// ─── Hotel Form Dialog ────────────────────────────────────────────────────────
type HotelFormMode = { type: 'create' } | { type: 'edit'; hotel: HotelRow }
function HotelFormDialog({
open,
mode,
programId,
onOpenChange,
}: {
open: boolean
mode: HotelFormMode
programId: string
onOpenChange: (next: boolean) => void
}) {
const utils = trpc.useUtils()
const [name, setName] = useState('')
const [address, setAddress] = useState('')
const [link, setLink] = useState('')
const [notes, setNotes] = useState('')
// Sync form state from server data on first load / after save.
useEffect(() => {
if (hotel) {
setName(hotel.name)
setAddress(hotel.address ?? '')
setLink(hotel.link ?? '')
setNotes(hotel.notes ?? '')
if (!open) return
if (mode.type === 'edit') {
setName(mode.hotel.name)
setAddress(mode.hotel.address ?? '')
setLink(mode.hotel.link ?? '')
setNotes(mode.hotel.notes ?? '')
} else {
setName('')
setAddress('')
setLink('')
setNotes('')
}
}, [hotel])
}, [open, mode])
const upsertMutation = trpc.logistics.upsertHotel.useMutation({
onSuccess: () => {
toast.success('Hotel saved')
utils.logistics.getHotel.invalidate({ programId })
},
const onSuccess = () => {
toast.success(mode.type === 'create' ? 'Hotel added' : 'Hotel updated')
utils.logistics.listHotels.invalidate({ programId })
onOpenChange(false)
}
const createMutation = trpc.logistics.createHotel.useMutation({
onSuccess,
onError: (err) => toast.error(err.message),
})
const updateMutation = trpc.logistics.updateHotel.useMutation({
onSuccess,
onError: (err) => toast.error(err.message),
})
const isPending = createMutation.isPending || updateMutation.isPending
const handleSave = () => {
if (!name.trim()) {
toast.error('Hotel name is required')
return
}
upsertMutation.mutate({
programId,
name: name.trim(),
address: address.trim() || undefined,
link: link.trim() || '',
notes: notes.trim() || undefined,
if (mode.type === 'create') {
createMutation.mutate({
programId,
name: name.trim(),
address: address.trim() || undefined,
link: link.trim() || undefined,
notes: notes.trim() || undefined,
})
} else {
updateMutation.mutate({
id: mode.hotel.id,
name: name.trim(),
address: address.trim() || null,
link: link.trim() || null,
notes: notes.trim() || null,
})
}
}
return (
<Dialog open={open} onOpenChange={(next) => { if (!isPending) onOpenChange(next) }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{mode.type === 'create' ? 'Add hotel' : 'Edit hotel'}</DialogTitle>
<DialogDescription>
{mode.type === 'create'
? 'Add a hotel that finalists can be assigned to.'
: 'Update hotel details.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="hotel-name">Name *</Label>
<Input
id="hotel-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Hôtel de Paris"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="hotel-address">Address</Label>
<Textarea
id="hotel-address"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="Place du Casino, 98000 Monaco"
rows={2}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="hotel-link">Website / booking link</Label>
<Input
id="hotel-link"
type="url"
value={link}
onChange={(e) => setLink(e.target.value)}
placeholder="https://hoteldeparismontecarlo.com"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="hotel-notes">Internal notes</Label>
<Textarea
id="hotel-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Check-in time, special arrangements, etc."
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{mode.type === 'create' ? 'Add hotel' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ─── Hotels Section ───────────────────────────────────────────────────────────
function HotelsSection({ programId }: { programId: string }) {
const utils = trpc.useUtils()
const { data: hotels, isLoading } = trpc.logistics.listHotels.useQuery({ programId })
const [dialogOpen, setDialogOpen] = useState(false)
const [dialogMode, setDialogMode] = useState<HotelFormMode>({ type: 'create' })
const deleteMutation = trpc.logistics.deleteHotel.useMutation({
onSuccess: () => {
toast.success('Hotel removed')
utils.logistics.listHotels.invalidate({ programId })
utils.logistics.listRooming.invalidate({ programId })
},
onError: (err) => toast.error(err.message),
})
const openCreate = () => {
setDialogMode({ type: 'create' })
setDialogOpen(true)
}
const openEdit = (hotel: HotelRow) => {
setDialogMode({ type: 'edit', hotel })
setDialogOpen(true)
}
return (
<>
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<HotelIcon className="text-muted-foreground h-4 w-4" />
<CardTitle className="text-base">Hotels</CardTitle>
</div>
<Button size="sm" onClick={openCreate}>
<Plus className="mr-1 h-4 w-4" />
Add hotel
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{[1, 2].map((i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : !hotels || hotels.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
No hotels yet. Add one above.
</p>
) : (
<div className="space-y-3">
{hotels.map((hotel) => (
<div
key={hotel.id}
className="flex items-start justify-between gap-4 rounded-md border p-3"
>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium">{hotel.name}</span>
<Badge variant="secondary" className="text-xs">
{hotel._count.stays} guest{hotel._count.stays !== 1 ? 's' : ''}
</Badge>
</div>
{hotel.address && (
<p className="text-muted-foreground text-xs whitespace-pre-line">{hotel.address}</p>
)}
{hotel.link && (
<a
href={hotel.link}
target="_blank"
rel="noopener noreferrer"
className="text-primary inline-flex items-center gap-1 text-xs hover:underline"
>
Visit website <ExternalLink className="h-3 w-3" />
</a>
)}
{hotel.notes && (
<p className="text-muted-foreground text-xs italic">{hotel.notes}</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => openEdit(hotel)}
>
<Pencil className="h-4 w-4" />
<span className="sr-only">Edit</span>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive h-8 w-8"
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete hotel?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove <strong>{hotel.name}</strong>.
{hotel._count.stays > 0
? ` Reassign the ${hotel._count.stays} guest(s) before deleting.`
: ' This action cannot be undone.'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteMutation.mutate({ id: hotel.id })}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<HotelFormDialog
open={dialogOpen}
mode={dialogMode}
programId={programId}
onOpenChange={setDialogOpen}
/>
</>
)
}
// ─── Attendee Row ─────────────────────────────────────────────────────────────
function AttendeeRoomRow({
row,
hotels,
programId,
}: {
row: RoomingRow
hotels: HotelRow[]
programId: string
}) {
const utils = trpc.useUtils()
const [roomNumber, setRoomNumber] = useState(row.stay?.roomNumber ?? '')
const [checkIn, setCheckIn] = useState(toDateInputValue(row.stay?.checkInAt ?? null))
const [checkOut, setCheckOut] = useState(toDateInputValue(row.stay?.checkOutAt ?? null))
// Keep local state in sync when server data updates
const prevStayRef = useRef(row.stay)
useEffect(() => {
const prev = prevStayRef.current
const cur = row.stay
// Only sync if the stay changed from outside (different hotelId or null/non-null)
if (prev?.hotelId !== cur?.hotelId || (prev === null) !== (cur === null)) {
setRoomNumber(cur?.roomNumber ?? '')
setCheckIn(toDateInputValue(cur?.checkInAt ?? null))
setCheckOut(toDateInputValue(cur?.checkOutAt ?? null))
}
prevStayRef.current = cur
}, [row.stay])
const assignMutation = trpc.logistics.assignStay.useMutation({
onSuccess: () => utils.logistics.listRooming.invalidate({ programId }),
onError: (err) => toast.error(err.message),
})
const unassignMutation = trpc.logistics.unassignStay.useMutation({
onSuccess: () => utils.logistics.listRooming.invalidate({ programId }),
onError: (err) => toast.error(err.message),
})
const currentHotelId = row.stay?.hotelId ?? ''
const handleHotelChange = (value: string) => {
if (!value || value === UNASSIGN_VALUE) {
unassignMutation.mutate({ attendingMemberId: row.attendingMemberId })
} else {
assignMutation.mutate({
attendingMemberId: row.attendingMemberId,
hotelId: value,
roomNumber: roomNumber.trim() || null,
checkInAt: fromDateInputValue(checkIn),
checkOutAt: fromDateInputValue(checkOut),
})
}
}
const commitRoomNumber = () => {
if (!currentHotelId) return
const trimmed = roomNumber.trim()
if (trimmed === (row.stay?.roomNumber ?? '')) return
assignMutation.mutate({
attendingMemberId: row.attendingMemberId,
hotelId: currentHotelId,
roomNumber: trimmed || null,
checkInAt: fromDateInputValue(checkIn),
checkOutAt: fromDateInputValue(checkOut),
})
}
if (isLoading) return <Skeleton className="h-96 w-full" />
const commitCheckIn = () => {
if (!currentHotelId) return
if (checkIn === toDateInputValue(row.stay?.checkInAt ?? null)) return
assignMutation.mutate({
attendingMemberId: row.attendingMemberId,
hotelId: currentHotelId,
roomNumber: roomNumber.trim() || null,
checkInAt: fromDateInputValue(checkIn),
checkOutAt: fromDateInputValue(checkOut),
})
}
const dirty =
name !== (hotel?.name ?? '') ||
address !== (hotel?.address ?? '') ||
link !== (hotel?.link ?? '') ||
notes !== (hotel?.notes ?? '')
const commitCheckOut = () => {
if (!currentHotelId) return
if (checkOut === toDateInputValue(row.stay?.checkOutAt ?? null)) return
assignMutation.mutate({
attendingMemberId: row.attendingMemberId,
hotelId: currentHotelId,
roomNumber: roomNumber.trim() || null,
checkInAt: fromDateInputValue(checkIn),
checkOutAt: fromDateInputValue(checkOut),
})
}
const isBusy = assignMutation.isPending || unassignMutation.isPending
const hasHotel = !!currentHotelId
return (
<div className="grid gap-4 md:grid-cols-3">
<div className="md:col-span-2">
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<HotelIcon className="text-muted-foreground h-4 w-4" />
<CardTitle className="text-base">Hotel for this edition</CardTitle>
</div>
<CardDescription>
One hotel per edition. Used in confirmation emails and finalist communications.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="hotel-name">Name *</Label>
<Input
id="hotel-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Hôtel de Paris"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="hotel-address">Address</Label>
<Textarea
id="hotel-address"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="Place du Casino, 98000 Monaco"
rows={2}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="hotel-link">Hotel website / booking link</Label>
<Input
id="hotel-link"
type="url"
value={link}
onChange={(e) => setLink(e.target.value)}
placeholder="https://hoteldeparismontecarlo.com"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="hotel-notes">Internal notes</Label>
<Textarea
id="hotel-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Check-in time, special arrangements, etc."
rows={3}
/>
</div>
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={!dirty || upsertMutation.isPending}
>
{upsertMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save
</Button>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-[1fr_auto] items-center gap-2 py-2 pl-4 sm:grid-cols-[2fr_2fr_1fr_1fr_1fr]">
{/* Member */}
<div className="col-span-2 sm:col-span-1">
<div className="text-sm font-medium">{row.user.name ?? row.user.email}</div>
<div className="text-muted-foreground text-xs">{row.user.email}</div>
</div>
<div className="md:col-span-1">
<Card>
<CardHeader>
<CardTitle className="text-base">Email preview</CardTitle>
<CardDescription>What teams will see in confirmation emails.</CardDescription>
</CardHeader>
<CardContent>
{!name.trim() ? (
<p className="text-muted-foreground text-sm">Save a hotel to see the preview.</p>
) : (
<div className="bg-muted/30 rounded-md border p-4 text-sm">
<div className="text-muted-foreground mb-1 text-xs uppercase tracking-wide">
Your accommodation
</div>
<div className="font-semibold">{name}</div>
{address.trim() && (
<div className="text-muted-foreground mt-1 whitespace-pre-line text-xs">
{address}
</div>
)}
{link.trim() && (
<a
href={link}
target="_blank"
rel="noopener noreferrer"
className="text-primary mt-2 inline-flex items-center gap-1 text-xs hover:underline"
>
Visit hotel website <ExternalLink className="h-3 w-3" />
</a>
)}
</div>
)}
</CardContent>
</Card>
</div>
{/* Hotel select */}
<Select value={currentHotelId} onValueChange={handleHotelChange} disabled={isBusy}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="— Unassigned —" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNASSIGN_VALUE}> Unassigned </SelectItem>
{hotels.map((h) => (
<SelectItem key={h.id} value={h.id}>
{h.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Room # */}
<Input
className="h-8 text-xs"
placeholder="Room #"
value={roomNumber}
onChange={(e) => setRoomNumber(e.target.value)}
onBlur={commitRoomNumber}
disabled={!hasHotel || isBusy}
/>
{/* Check-in */}
<Input
className="h-8 text-xs"
type="date"
value={checkIn}
onChange={(e) => setCheckIn(e.target.value)}
onBlur={commitCheckIn}
disabled={!hasHotel || isBusy}
/>
{/* Check-out */}
<Input
className="h-8 text-xs"
type="date"
value={checkOut}
onChange={(e) => setCheckOut(e.target.value)}
onBlur={commitCheckOut}
disabled={!hasHotel || isBusy}
/>
</div>
)
}
// ─── Rooming Section ──────────────────────────────────────────────────────────
function RoomingSection({ programId }: { programId: string }) {
const utils = trpc.useUtils()
const { data: rooming, isLoading: roomingLoading } = trpc.logistics.listRooming.useQuery({ programId })
const { data: hotels } = trpc.logistics.listHotels.useQuery({ programId })
const assignTeamMutation = trpc.logistics.assignTeamToHotel.useMutation({
onSuccess: () => {
toast.success('Team assigned')
utils.logistics.listRooming.invalidate({ programId })
utils.logistics.listHotels.invalidate({ programId })
},
onError: (err) => toast.error(err.message),
})
// Group rows by projectTitle
const grouped = useMemo(() => {
if (!rooming) return []
const map = new Map<string, { confirmationId: string; projectTitle: string; rows: RoomingRow[] }>()
for (const row of rooming) {
if (!map.has(row.projectId)) {
map.set(row.projectId, {
confirmationId: row.confirmationId,
projectTitle: row.projectTitle,
rows: [],
})
}
map.get(row.projectId)!.rows.push(row)
}
return Array.from(map.values())
}, [rooming])
const downloadCsv = () => {
if (!rooming || !hotels) return
const csv = buildRoomingCsv(rooming, hotels)
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'rooming-manifest.csv'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-4">
<CardTitle className="text-base">Rooming</CardTitle>
<Button
variant="outline"
size="sm"
disabled={!rooming || rooming.length === 0}
onClick={downloadCsv}
>
<Download className="mr-1 h-4 w-4" /> Download CSV
</Button>
</div>
</CardHeader>
<CardContent>
{roomingLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : grouped.length === 0 ? (
<p className="text-muted-foreground py-12 text-center text-sm">
No confirmed attendees yet.
</p>
) : (
<div className="space-y-6">
{grouped.map((group) => (
<div key={group.confirmationId}>
{/* Team header */}
<div className="bg-muted/40 flex items-center justify-between gap-4 rounded-t-md border px-3 py-2">
<span className="text-sm font-semibold">{group.projectTitle}</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs shrink-0">Assign whole team to</span>
<Select
value=""
onValueChange={(hotelId) => {
if (!hotelId) return
assignTeamMutation.mutate({
confirmationId: group.confirmationId,
hotelId,
})
}}
disabled={assignTeamMutation.isPending || !hotels || hotels.length === 0}
>
<SelectTrigger className="h-7 w-40 text-xs">
<SelectValue placeholder="Select hotel…" />
</SelectTrigger>
<SelectContent>
{(hotels ?? []).map((h) => (
<SelectItem key={h.id} value={h.id}>
{h.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Column headers */}
<div className="hidden grid-cols-[2fr_2fr_1fr_1fr_1fr] gap-2 border-x border-b bg-white px-4 py-1 sm:grid">
<span className="text-muted-foreground text-xs font-medium">Member</span>
<span className="text-muted-foreground text-xs font-medium">Hotel</span>
<span className="text-muted-foreground text-xs font-medium">Room #</span>
<span className="text-muted-foreground text-xs font-medium">Check-in</span>
<span className="text-muted-foreground text-xs font-medium">Check-out</span>
</div>
{/* Attendee rows */}
<div className="divide-y rounded-b-md border-x border-b">
{group.rows.map((row) => (
<AttendeeRoomRow
key={row.attendingMemberId}
row={row}
hotels={hotels ?? []}
programId={programId}
/>
))}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}
// ─── Main export ──────────────────────────────────────────────────────────────
export function HotelsTab({ programId }: Props) {
return (
<div className="space-y-6">
<HotelsSection programId={programId} />
<RoomingSection programId={programId} />
</div>
)
}

View File

@@ -22,7 +22,8 @@ import {
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Plus, Pencil, Trash2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Plus, Pencil, Trash2, Mail, MailCheck, Utensils } from 'lucide-react'
import { toast } from 'sonner'
const ALLERGENS = [
@@ -90,6 +91,13 @@ export const LunchExternals = forwardRef<
onSuccess: invalidateAll,
onError: (e) => toast.error(e.message),
})
const sendInvite = trpc.lunch.sendExternalInvite.useMutation({
onSuccess: () => {
invalidateAll()
toast.success('Dish invite sent')
},
onError: (e) => toast.error(e.message),
})
const editingRow =
editing?.mode === 'edit'
@@ -123,7 +131,40 @@ export const LunchExternals = forwardRef<
{e.project?.title ?? 'Standalone'}
</td>
<td className="text-muted-foreground">{e.roleNote ?? ''}</td>
<td>
{e.dishId ? (
<Badge variant="secondary" className="gap-1">
<Utensils className="h-3 w-3" /> Picked
</Badge>
) : !e.email ? (
<Badge variant="outline" className="text-muted-foreground">
No email
</Badge>
) : e.inviteSentAt ? (
<Badge variant="outline" className="gap-1">
<MailCheck className="h-3 w-3" /> Invited
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
Not invited
</Badge>
)}
</td>
<td className="text-right">
{e.email && !e.dishId && (
<Button
size="sm"
variant="ghost"
title={e.inviteSentAt ? 'Resend dish invite' : 'Send dish invite'}
disabled={
sendInvite.isPending &&
sendInvite.variables?.externalId === e.id
}
onClick={() => sendInvite.mutate({ externalId: e.id })}
>
<Mail className="h-4 w-4" />
</Button>
)}
<Button
size="sm"
variant="ghost"

View File

@@ -11,15 +11,28 @@ import {
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Send, Eye } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Send, Eye, Bell } from 'lucide-react'
import { toast } from 'sonner'
export function LunchRecapActions({
programId,
lunchEventId,
recapSentAt,
extraRecipientCount,
}: {
programId: string
lunchEventId: string
recapSentAt: Date | null
extraRecipientCount: number
}) {
@@ -46,6 +59,15 @@ export function LunchRecapActions({
},
})
const sendReminders = trpc.lunch.sendReminders.useMutation({
onSuccess: (data) => {
toast.success(`Reminders sent to ${data.sent} attendee${data.sent === 1 ? '' : 's'}`)
},
onError: (e) => {
toast.error(`Failed to send reminders: ${e.message}`)
},
})
const { data: preview, isLoading: loadingPreview } =
trpc.lunch.getRecapPreview.useQuery(
{ programId },
@@ -68,6 +90,31 @@ export function LunchRecapActions({
>
<Send className="mr-2 h-4 w-4" /> Send recap now
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" disabled={sendReminders.isPending}>
<Bell className="mr-2 h-4 w-4" /> Send reminders now
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Send lunch pick reminders?</AlertDialogTitle>
<AlertDialogDescription>
This will send a reminder email to all confirmed attendees who
haven&apos;t picked a lunch dish yet. You can do this multiple
times it won&apos;t affect the automatic reminder window.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => sendReminders.mutate({ lunchEventId })}
>
Send reminders
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<p className="text-muted-foreground text-xs">
{recapSentAt

View File

@@ -32,6 +32,7 @@ export function LunchTab({ programId }: { programId: string }) {
/>
<LunchRecapActions
programId={programId}
lunchEventId={event.id}
recapSentAt={event.recapSentAt}
extraRecipientCount={event.extraRecipients.length}
/>

View File

@@ -26,7 +26,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Loader2, Plane } from 'lucide-react'
import { Download, Loader2, Plane } from 'lucide-react'
import type { FlightDetailStatus } from '@prisma/client'
interface Props {
@@ -240,6 +240,47 @@ function FlightEditorSheet({
)
}
function csvEscape(value: string | null | undefined): string {
const str = value ?? ''
if (str.includes('"') || str.includes(',') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
function buildTravelCsv(rows: AttendeeRow[]): string {
const header = [
'Project',
'Attendee',
'Email',
'Arrival date/time',
'Arrival flight',
'Arrival airport',
'Departure date/time',
'Departure flight',
'Departure airport',
'Status',
'Needs visa',
].join(',')
const lines = rows.map((r) => {
const fd = r.flightDetail
return [
csvEscape(r.confirmation.project.title),
csvEscape(r.user.name ?? r.user.email),
csvEscape(r.user.email),
csvEscape(fd?.arrivalAt ? new Date(fd.arrivalAt).toLocaleString() : ''),
csvEscape(fd?.arrivalFlightNumber),
csvEscape(fd?.arrivalAirport),
csvEscape(fd?.departureAt ? new Date(fd.departureAt).toLocaleString() : ''),
csvEscape(fd?.departureFlightNumber),
csvEscape(fd?.departureAirport),
csvEscape(fd?.status ?? ''),
r.needsVisa ? 'Yes' : 'No',
].join(',')
})
return [header, ...lines].join('\r\n')
}
export function TravelTab({ programId }: Props) {
const utils = trpc.useUtils()
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
@@ -306,11 +347,31 @@ export function TravelTab({ programId }: Props) {
<Plane className="text-muted-foreground h-4 w-4" />
<CardTitle className="text-base">Travel for confirmed finalists</CardTitle>
</div>
<div className="flex flex-wrap gap-1.5">
<div className="flex flex-wrap items-center gap-1.5">
<StatusPill value="all" label="All" count={totals.all} />
<StatusPill value="unfilled" label="Unfilled" count={totals.unfilled} />
<StatusPill value="PENDING" label="Pending" count={totals.PENDING} />
<StatusPill value="CONFIRMED" label="Confirmed" count={totals.CONFIRMED} />
<Button
variant="outline"
size="sm"
disabled={!data || data.length === 0}
onClick={() => {
if (!data) return
const csv = buildTravelCsv(data as AttendeeRow[])
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'travel-manifest.csv'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}}
>
<Download className="mr-1 h-4 w-4" /> Download CSV
</Button>
</div>
</div>
</CardHeader>

View File

@@ -15,7 +15,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Settings as SettingsIcon, ShieldOff } from 'lucide-react'
import { Download, Settings as SettingsIcon, ShieldOff } from 'lucide-react'
import { VisaEditDialog, type VisaEditTarget } from './visa-edit-dialog'
import type { VisaStatus } from '@prisma/client'
@@ -56,6 +56,53 @@ function nextDate(row: {
return { label: '—', date: null }
}
function csvEscape(value: string | null | undefined): string {
const str = value ?? ''
if (str.includes('"') || str.includes(',') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
type VisaRow = {
status: VisaStatus
nationality: string | null
invitationSentAt: Date | null
appointmentAt: Date | null
decisionAt: Date | null
notes: string | null
project: { id: string; title: string }
attendee: { id: string; user: { id: string; name: string | null; email: string } }
}
function buildVisaCsv(rows: VisaRow[]): string {
const header = [
'Project',
'Attendee',
'Email',
'Nationality',
'Status',
'Invitation sent',
'Appointment',
'Decision',
'Notes',
].join(',')
const lines = rows.map((r) => {
return [
csvEscape(r.project.title),
csvEscape(r.attendee.user.name ?? r.attendee.user.email),
csvEscape(r.attendee.user.email),
csvEscape(r.nationality),
csvEscape(r.status),
csvEscape(r.invitationSentAt ? new Date(r.invitationSentAt).toLocaleDateString() : ''),
csvEscape(r.appointmentAt ? new Date(r.appointmentAt).toLocaleDateString() : ''),
csvEscape(r.decisionAt ? new Date(r.decisionAt).toLocaleDateString() : ''),
csvEscape(r.notes),
].join(',')
})
return [header, ...lines].join('\r\n')
}
export function VisasTab({ programId }: Props) {
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [editTarget, setEditTarget] = useState<VisaEditTarget | null>(null)
@@ -117,12 +164,34 @@ export function VisasTab({ programId }: Props) {
continue to flow over email and are never stored on this platform.
</p>
</div>
<Button variant="outline" size="sm" asChild>
<Link href="/admin/settings?tab=edition">
<SettingsIcon className="mr-2 h-3.5 w-3.5" />
Edition settings
</Link>
</Button>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={!data || data.length === 0}
onClick={() => {
if (!data) return
const csv = buildVisaCsv(data)
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'visa-applications.csv'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}}
>
<Download className="mr-1 h-4 w-4" /> Download CSV
</Button>
<Button variant="outline" size="sm" asChild>
<Link href="/admin/settings?tab=edition">
<SettingsIcon className="mr-2 h-3.5 w-3.5" />
Edition settings
</Link>
</Button>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
<StatusPill value="all" label="All" count={(data ?? []).length} />

View File

@@ -0,0 +1,64 @@
'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 || status.requirements.length === 0) 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 optionalMode = !status.hasRequired
const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded
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
? optionalMode ? 'Grand Final documents uploaded' : 'Grand Final documents submitted'
: optionalMode ? 'Upload updated Grand Final documents (optional)' : 'Upload your Grand Final documents'}
</span>
<span className="text-sm text-muted-foreground">({uploadedCount} of {total})</span>
</div>
{status.deadline && (
<span className={`flex items-center gap-1.5 text-sm ${status.deadlinePassed ? 'text-destructive' : 'text-muted-foreground'}`}>
<Clock className="h-4 w-4" />
{status.deadlinePassed ? 'Deadline passed' : 'Due'}: {fmt.format(new Date(status.deadline))}
{zone ? ` (${zone})` : ''}
</span>
)}
</div>
<div className="flex flex-wrap gap-3">
{status.requirements.map((r) => {
const Icon = r.acceptedMimeTypes.some((m) => m.startsWith('video/')) ? Video : FileText
return (
<span key={r.id} className="flex items-center gap-1.5 text-sm">
{r.uploaded ? <CheckCircle2 className="h-4 w-4 text-emerald-600" /> : <Circle className="h-4 w-4 text-muted-foreground/50" />}
<Icon className="h-4 w-4 text-muted-foreground" />
{r.name}
</span>
)
})}
</div>
{!done && (
<Button asChild size="sm" className="bg-brand-blue hover:bg-brand-blue-light">
<Link href="/applicant/documents">
<Upload className="mr-2 h-4 w-4" /> Upload documents
</Link>
</Button>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,61 @@
'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 || status.requirements.length === 0) return null
const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded
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>
{done
? <Badge className="bg-emerald-50 text-emerald-700 border-emerald-200">{status.hasRequired ? 'Submitted' : 'Uploaded'}</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.'}
{!status.hasRequired && ' These uploads are optional.'}
</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' && !done && (
<Button asChild size="sm" className="mt-2 bg-brand-blue hover:bg-brand-blue-light">
<Link href="/applicant/documents"><Upload className="mr-2 h-4 w-4" /> Upload documents</Link>
</Button>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,323 @@
'use client'
import { useState } from 'react'
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 { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Hotel, PlaneLanding, PlaneTakeoff, ShieldCheck, Pencil, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import type { FlightDetailStatus, VisaStatus } from '@prisma/client'
const FLIGHT_BADGE: Record<
FlightDetailStatus,
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
> = {
PENDING: { label: 'Pending confirmation', variant: 'secondary' },
CONFIRMED: { label: 'Flight confirmed', variant: 'default' },
}
const VISA_BADGE: Record<
VisaStatus,
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
> = {
NOT_NEEDED: { label: 'Visa not needed', variant: 'outline' },
REQUESTED: { label: 'Visa requested', variant: 'secondary' },
INVITATION_SENT: { label: 'Invitation sent', variant: 'secondary' },
APPOINTMENT_BOOKED: { label: 'Appointment booked', variant: 'default' },
GRANTED: { label: 'Visa granted', variant: 'default' },
DENIED: { label: 'Visa denied', variant: 'destructive' },
}
function formatMonacoTime(d: Date | string | null | undefined): string {
if (!d) return '—'
return new Date(d).toLocaleString('en-GB', {
timeZone: 'Europe/Paris',
dateStyle: 'medium',
timeStyle: 'short',
})
}
function FlightRow({
label,
icon,
flightNumber,
airport,
at,
}: {
label: string
icon: React.ReactNode
flightNumber: string | null | undefined
airport: string | null | undefined
at: Date | string | null | undefined
}) {
const hasData = flightNumber || airport || at
if (!hasData) return null
return (
<div className="space-y-0.5">
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide">
{icon}
{label}
</div>
<div className="text-sm">
{[flightNumber, airport].filter(Boolean).join(' · ')}
{at && (
<span className="text-muted-foreground ml-1">
{formatMonacoTime(at)}{' '}
<span className="text-xs">(Monaco time)</span>
</span>
)}
</div>
</div>
)
}
function NationalityField({
currentNationality,
onSaved,
}: {
currentNationality: string | null | undefined
onSaved: () => void
}) {
const [editing, setEditing] = useState(!currentNationality)
const [value, setValue] = useState(currentNationality ?? '')
const utils = trpc.useUtils()
const mutation = trpc.applicant.updateMyVisaNationality.useMutation({
onSuccess: () => {
toast.success('Nationality saved.')
utils.applicant.getMyLogistics.invalidate()
setEditing(false)
onSaved()
},
onError: (err) => {
toast.error(err.message ?? 'Could not save nationality.')
},
})
if (!editing && currentNationality) {
return (
<div className="flex items-center gap-2">
<span className="text-sm">{currentNationality}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => {
setValue(currentNationality)
setEditing(true)
}}
aria-label="Edit nationality"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
</div>
)
}
return (
<div className="flex items-center gap-2">
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="e.g. French"
className="h-8 max-w-[200px] text-sm"
disabled={mutation.isPending}
/>
<Button
size="sm"
className="h-8"
disabled={!value.trim() || mutation.isPending}
onClick={() => mutation.mutate({ nationality: value.trim() })}
>
{mutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
</Button>
{currentNationality && (
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => {
setValue(currentNationality)
setEditing(false)
}}
disabled={mutation.isPending}
>
Cancel
</Button>
)}
</div>
)
}
export function MyLogisticsCard() {
const { data, isLoading } = trpc.applicant.getMyLogistics.useQuery()
if (isLoading) {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-56" />
</CardHeader>
<CardContent>
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
)
}
if (!data) return null
const { hotel, room, myFlight, visaVisible, myVisa } = data
const hasFlightData =
myFlight &&
(myFlight.arrivalAt ||
myFlight.arrivalFlightNumber ||
myFlight.arrivalAirport ||
myFlight.departureAt ||
myFlight.departureFlightNumber ||
myFlight.departureAirport)
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-teal-500/10 p-1.5">
<Hotel className="h-4 w-4 text-teal-600" />
</div>
Your grand-finale logistics
</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
{/* Hotel */}
<div className="space-y-1">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Hotel
</p>
{hotel ? (
<div className="space-y-0.5">
<p className="text-sm font-medium">
{hotel.link ? (
<a
href={hotel.link}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-4"
>
{hotel.name}
</a>
) : (
hotel.name
)}
</p>
{hotel.address && (
<p className="text-muted-foreground text-sm">{hotel.address}</p>
)}
</div>
) : (
<p className="text-muted-foreground text-sm">Hotel details coming soon.</p>
)}
</div>
{/* Room */}
{room && (
<div className="space-y-1">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Room
</p>
<div className="space-y-0.5">
{room.roomNumber && (
<p className="text-sm font-medium">Room {room.roomNumber}</p>
)}
{room.checkInAt && (
<p className="text-muted-foreground text-sm">
Check-in:{' '}
<span className="text-foreground">
{formatMonacoTime(room.checkInAt)}
</span>{' '}
<span className="text-xs text-muted-foreground">(Monaco time)</span>
</p>
)}
{room.checkOutAt && (
<p className="text-muted-foreground text-sm">
Check-out:{' '}
<span className="text-foreground">
{formatMonacoTime(room.checkOutAt)}
</span>{' '}
<span className="text-xs text-muted-foreground">(Monaco time)</span>
</p>
)}
</div>
</div>
)}
{/* Flights */}
<div className="space-y-2">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Flights
</p>
{hasFlightData ? (
<div className="space-y-3">
<FlightRow
label="Arrival"
icon={<PlaneLanding className="h-3 w-3" />}
flightNumber={myFlight.arrivalFlightNumber}
airport={myFlight.arrivalAirport}
at={myFlight.arrivalAt}
/>
<FlightRow
label="Departure"
icon={<PlaneTakeoff className="h-3 w-3" />}
flightNumber={myFlight.departureFlightNumber}
airport={myFlight.departureAirport}
at={myFlight.departureAt}
/>
<Badge variant={FLIGHT_BADGE[myFlight.status].variant}>
{FLIGHT_BADGE[myFlight.status].label}
</Badge>
</div>
) : (
<p className="text-muted-foreground text-sm">
Your flight details will appear here once arranged.
</p>
)}
</div>
{/* Visa — only when visaVisible */}
{visaVisible && (
<div className="space-y-2">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Visa
</p>
<div className="space-y-2">
<Badge
variant={myVisa ? VISA_BADGE[myVisa.status].variant : 'outline'}
className="gap-1"
>
<ShieldCheck className="h-3 w-3" />
{myVisa ? VISA_BADGE[myVisa.status].label : 'Not started'}
</Badge>
{myVisa && (
<div className="space-y-1">
<p className="text-muted-foreground text-xs">Passport nationality</p>
<NationalityField
currentNationality={myVisa.nationality}
onSaved={() => {}}
/>
</div>
)}
</div>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,125 @@
'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>
)
}
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.totalCount} finalist team{data.totalCount === 1 ? '' : 's'} · every file each team
has submitted across all rounds
</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="outline">
{team.files.length} file{team.files.length === 1 ? '' : 's'}
</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
{team.files.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center md:col-span-2">
No files submitted.
</p>
)}
{team.files.map((f) => (
<div key={f.id} className="rounded-lg border p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-sm flex items-center gap-2 min-w-0">
<FileText className="h-4 w-4 shrink-0" />
<span className="truncate">{f.docLabel}</span>
</span>
<Button asChild variant="ghost" size="sm" className="h-7 px-2 text-xs shrink-0">
<a href={f.url} target="_blank" rel="noreferrer">
<Download className="h-3 w-3 mr-1" /> Open
</a>
</Button>
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<Badge variant="outline" className="text-[10px]">
{f.roundLabel}
</Badge>
{f.isFinaleUpload && (
<Badge className="text-[10px] bg-amber-50 text-amber-700 border-amber-200">
Revised for finals
</Badge>
)}
</div>
<FilePreview
file={{ mimeType: f.mimeType, fileName: f.fileName }}
url={f.url}
/>
</div>
))}
</CardContent>
</Card>
))}
</div>
)
}

View File

@@ -1,11 +1,12 @@
'use client'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Slider } from '@/components/ui/slider'
import { Textarea } from '@/components/ui/textarea'
import {
AlertDialog,
AlertDialogAction,
@@ -16,7 +17,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { CheckCircle2 } from 'lucide-react'
import { CheckCircle2, Pencil } from 'lucide-react'
interface LiveVotingCriterion {
id: string
@@ -30,12 +31,19 @@ interface LiveVotingFormProps {
projectId: string
votingMode?: 'simple' | 'criteria'
criteria?: LiveVotingCriterion[]
onVoteSubmit: (vote: { score: number; criterionScores?: Record<string, number> }) => void
onVoteSubmit: (vote: {
score: number
criterionScores?: Record<string, number>
comment?: string
}) => void
disabled?: boolean
existingVote?: {
score: number
criterionScoresJson?: Record<string, number>
comment?: string | null
} | null
/** Visual emphasis when the admin opens the scoring phase */
highlighted?: boolean
}
export function LiveVotingForm({
@@ -45,73 +53,115 @@ export function LiveVotingForm({
onVoteSubmit,
disabled = false,
existingVote,
highlighted = false,
}: LiveVotingFormProps) {
const [score, setScore] = useState(existingVote?.score ?? 50)
const [score, setScore] = useState(existingVote?.score ?? 5)
const [criterionScores, setCriterionScores] = useState<Record<string, number>>(
existingVote?.criterionScoresJson ?? {}
)
const [comment, setComment] = useState(existingVote?.comment ?? '')
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false)
const [hasSubmitted, setHasSubmitted] = useState(!!existingVote)
const [editing, setEditing] = useState(!existingVote)
const handleSubmit = () => {
setConfirmDialogOpen(true)
}
// When the ceremony cursor moves to a new project, reset the form state
useEffect(() => {
setScore(existingVote?.score ?? 5)
setCriterionScores(existingVote?.criterionScoresJson ?? {})
setComment(existingVote?.comment ?? '')
setEditing(!existingVote)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId])
const handleConfirm = () => {
if (votingMode === 'criteria' && criteria) {
// Compute weighted score for display
// The server recomputes the weighted score from criterionScores; this
// 1-10 value is only a fallback and MUST stay within the API's range.
let weightedSum = 0
for (const c of criteria) {
const normalizedScore = (criterionScores[c.id] / c.scale) * 10
const normalizedScore = ((criterionScores[c.id] ?? 0) / c.scale) * 10
weightedSum += normalizedScore * c.weight
}
const computedScore = Math.round(Math.min(10, Math.max(1, weightedSum))) * 10 // Scale to 100 for display
const computedScore = Math.round(Math.min(10, Math.max(1, weightedSum)))
onVoteSubmit({
score: computedScore,
criterionScores,
comment: comment.trim() || undefined,
})
} else {
onVoteSubmit({ score })
onVoteSubmit({ score, comment: comment.trim() || undefined })
}
setHasSubmitted(true)
// NOTE: editing is NOT flipped here — the parent re-keys this component
// when the persisted vote actually lands, so a failed mutation leaves the
// form editable instead of lying with a "submitted" state.
setConfirmDialogOpen(false)
}
if (hasSubmitted || disabled) {
if (!editing) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<CheckCircle2 className="mb-4 h-12 w-12 text-green-600" />
<Card className={highlighted ? 'ring-2 ring-[#de0f1e]' : undefined}>
<CardContent className="flex flex-col items-center justify-center py-10">
<CheckCircle2 className="mb-3 h-12 w-12 text-green-600" />
<p className="font-medium">Vote Submitted</p>
{votingMode === 'simple' && (
<p className="mt-1 text-sm text-muted-foreground">Score: {score}/100</p>
)}
{votingMode === 'criteria' && criteria && (
{votingMode === 'criteria' && criteria ? (
<div className="mt-3 text-sm text-muted-foreground space-y-1">
{criteria.map((c) => (
<div key={c.id} className="flex justify-between gap-4">
<div key={c.id} className="flex justify-between gap-6">
<span>{c.label}:</span>
<span className="font-medium">{criterionScores[c.id] ?? 0}/{c.scale}</span>
<span className="font-medium">
{criterionScores[c.id] ?? 0}/{c.scale}
</span>
</div>
))}
</div>
) : (
<p className="mt-1 text-sm text-muted-foreground">Score: {score}/10</p>
)}
{comment.trim() && (
<p className="mt-3 max-w-md text-center text-xs italic text-muted-foreground">
{comment.trim()}
</p>
)}
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => setEditing(true)}
disabled={disabled}
>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit vote
</Button>
<p className="mt-2 text-xs text-muted-foreground">
You can revise your vote until the session closes
</p>
</CardContent>
</Card>
)
}
const commentField = (
<div className="space-y-2">
<Label className="text-sm">Comment (optional)</Label>
<Textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Visible to admins alongside your scores…"
rows={2}
/>
</div>
)
// Criteria-based voting
if (votingMode === 'criteria' && criteria && criteria.length > 0) {
const allScored = criteria.every((c) => criterionScores[c.id] !== undefined && criterionScores[c.id] > 0)
const allScored = criteria.every(
(c) => criterionScores[c.id] !== undefined && criterionScores[c.id] > 0
)
return (
<>
<Card>
<Card className={highlighted ? 'ring-2 ring-[#de0f1e]' : undefined}>
<CardHeader>
<CardTitle>Criteria-Based Voting</CardTitle>
<CardTitle>Score This Project</CardTitle>
<CardDescription>Score each criterion individually</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
@@ -153,7 +203,7 @@ export function LiveVotingForm({
onValueChange={(values) =>
setCriterionScores({
...criterionScores,
[criterion.id]: values[0],
[criterion.id]: Math.max(1, values[0]),
})
}
min={0}
@@ -164,13 +214,15 @@ export function LiveVotingForm({
</div>
))}
{commentField}
<Button
onClick={handleSubmit}
disabled={!allScored}
onClick={() => setConfirmDialogOpen(true)}
disabled={!allScored || disabled}
className="w-full"
size="lg"
>
Submit Vote
{existingVote ? 'Update Vote' : 'Submit Vote'}
</Button>
</CardContent>
</Card>
@@ -179,17 +231,19 @@ export function LiveVotingForm({
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Your Vote</AlertDialogTitle>
<AlertDialogDescription>
<AlertDialogDescription asChild>
<div className="space-y-2 mt-2">
<p className="font-medium">Your scores:</p>
{criteria.map((c) => (
<div key={c.id} className="flex justify-between text-sm">
<span>{c.label}:</span>
<span className="font-semibold">{criterionScores[c.id]}/{c.scale}</span>
<span className="font-semibold">
{criterionScores[c.id]}/{c.scale}
</span>
</div>
))}
<p className="text-xs text-muted-foreground mt-3">
This action cannot be undone. Are you sure?
You can still revise your vote until the session closes.
</p>
</div>
</AlertDialogDescription>
@@ -204,13 +258,13 @@ export function LiveVotingForm({
)
}
// Simple voting (0-100 slider)
// Simple voting (1-10 slider — matches the API contract)
return (
<>
<Card>
<Card className={highlighted ? 'ring-2 ring-[#de0f1e]' : undefined}>
<CardHeader>
<CardTitle>Live Voting</CardTitle>
<CardDescription>Rate this project on a scale of 0-100</CardDescription>
<CardTitle>Score This Project</CardTitle>
<CardDescription>Rate this project on a scale of 1-10</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
@@ -219,10 +273,12 @@ export function LiveVotingForm({
<div className="flex items-center gap-3">
<Input
type="number"
min="0"
max="100"
min="1"
max="10"
value={score}
onChange={(e) => setScore(Math.min(100, Math.max(0, parseInt(e.target.value) || 0)))}
onChange={(e) =>
setScore(Math.min(10, Math.max(1, parseInt(e.target.value) || 1)))
}
className="w-20 text-center"
/>
<span className="text-2xl font-bold text-primary">{score}</span>
@@ -231,34 +287,35 @@ export function LiveVotingForm({
<Slider
value={[score]}
onValueChange={(values) => setScore(values[0])}
min={0}
max={100}
onValueChange={(values) => setScore(Math.max(1, values[0]))}
min={1}
max={10}
step={1}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>Poor (0)</span>
<span>Average (50)</span>
<span>Excellent (100)</span>
<span>Poor (1)</span>
<span>Average (5)</span>
<span>Excellent (10)</span>
</div>
</div>
<Button onClick={handleSubmit} className="w-full" size="lg">
Submit Vote
{commentField}
<Button onClick={() => setConfirmDialogOpen(true)} className="w-full" size="lg" disabled={disabled}>
{existingVote ? 'Update Vote' : 'Submit Vote'}
</Button>
</CardContent>
</Card>
{/* Confirmation Dialog */}
<AlertDialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Your Vote</AlertDialogTitle>
<AlertDialogDescription>
You are about to submit a score of <strong>{score}/100</strong>. This action cannot
be undone. Are you sure?
You are about to submit a score of <strong>{score}/10</strong>. You can still revise
it until the session closes.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>

View File

@@ -1,6 +1,6 @@
'use client'
import { BookOpen, Home, Trophy, ClipboardList } from 'lucide-react'
import { BookOpen, Home, Trophy, ClipboardList, FileText, Radio, Scale } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
@@ -47,6 +47,14 @@ export function JuryNav({ user }: JuryNavProps) {
{ refetchInterval: 60000 }
)
const { data: flags } = trpc.settings.getFeatureFlags.useQuery()
// Only Grand-Final jury members (and admins) can open the finalist documents
// review — hide the link from everyone else so they don't hit a dead "No access" page.
const { data: canReviewFinals } = trpc.finalist.canReviewDocuments.useQuery()
// Ceremony-day links: live scoring page while a ceremony cursor is active,
// plus any deliberation sessions the juror participates in.
const { data: ceremony } = trpc.live.getMyCeremonyContext.useQuery(undefined, {
refetchInterval: 30000,
})
const useExternal = flags?.learningHubExternal && flags.learningHubExternalUrl
@@ -61,6 +69,29 @@ export function JuryNav({ user }: JuryNavProps) {
href: '/jury/competitions',
icon: ClipboardList,
},
...(canReviewFinals
? [
{
name: 'Finalist Documents',
href: '/jury/finals-documents',
icon: FileText,
},
]
: []),
...(ceremony?.liveRoundId
? [
{
name: 'Live Ceremony',
href: `/jury/competitions/${ceremony.liveRoundId}/live`,
icon: Radio,
},
]
: []),
...(ceremony?.deliberations ?? []).map((d) => ({
name: `Deliberation — ${d.category === 'STARTUP' ? 'Startups' : 'Business Concepts'}`,
href: `/jury/competitions/deliberation/${d.id}`,
icon: Scale,
})),
...(myAwards && myAwards.length > 0
? [
{

View File

@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { toast } from 'sonner'
import { Users, Scale, GraduationCap, Eye, Shield, Mail, Loader2 } from 'lucide-react'
import { Users, Scale, GraduationCap, Eye, Shield, Mail, Loader2, Plane } from 'lucide-react'
// Category icons and labels
const CATEGORIES = {
@@ -17,6 +17,7 @@ const CATEGORIES = {
mentor: { label: 'Mentors', icon: GraduationCap },
observer: { label: 'Observers', icon: Eye },
admin: { label: 'Administrators', icon: Shield },
logistics: { label: 'Logistics', icon: Plane },
}
type NotificationSetting = {

View File

@@ -58,8 +58,13 @@ export const authConfig: NextAuthConfig = {
'/forgot-password',
'/reset-password',
'/apply',
'/lunch/pick', // external attendees pick a dish via signed token (no account)
'/vote', // audience QR voting at the grand finale (token-based, no account)
'/live-scores', // public live scoreboard
'/live/ceremony', // big-screen ceremony view (projector, no account)
'/api/auth',
'/api/trpc', // tRPC handles its own auth via procedures
'/api/cron', // cron endpoints self-guard via x-cron-secret (CRON_SECRET)
]
// Check if it's a public path

View File

@@ -336,7 +336,7 @@ function statCard(label: string, value: string | number): string {
// Email Templates
// =============================================================================
interface EmailTemplate {
export interface EmailTemplate {
subject: string
html: string
text: string
@@ -2188,6 +2188,326 @@ export function getAccountReminderTemplate(
}
}
// =============================================================================
// Logistics email templates
// =============================================================================
/**
* Reminder email sent to a team lead when the confirmation deadline is approaching.
*/
function getFinalistReminderTemplate(
name: string,
projectTitle: string,
deadline: Date,
confirmUrl: string,
): EmailTemplate {
const greeting = name ? `Hi ${name},` : 'Hi there,'
const formattedDeadline = deadline.toLocaleString('en-GB', {
timeZone: 'Europe/Paris',
dateStyle: 'full',
timeStyle: 'short',
})
const content = `
${sectionTitle('Reminder: Grand-Finale Attendance')}
${paragraph(greeting)}
${paragraph(`Your project <strong>${escapeHtml(projectTitle)}</strong> has been selected as a finalist for the Monaco Ocean Protection Challenge Grand Finale. Your confirmation is still pending.`)}
${infoBox(`Please confirm your attendance <strong>by ${escapeHtml(formattedDeadline)} (Paris time)</strong>. If no response is received by the deadline, your slot may be reallocated.`, 'warning')}
${ctaButton(confirmUrl, 'Confirm attendance')}
${paragraph('If you have any questions, please reach out to the MOPC team.')}
`
const text = [
greeting,
'',
`Your project "${projectTitle}" has been selected as a finalist for the Monaco Ocean Protection Challenge Grand Finale. Your confirmation is still pending.`,
'',
`Please confirm your attendance by ${formattedDeadline} (Paris time). If no response is received by the deadline, your slot may be reallocated.`,
'',
`Confirm attendance: ${confirmUrl}`,
'',
'If you have any questions, please reach out to the MOPC team.',
'',
'The MOPC team',
].join('\n')
return {
subject: 'Reminder: confirm your grand-finale attendance',
html: getEmailWrapper(content),
text,
}
}
/**
* 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.
*/
function getFinalistWithdrawnTemplate(
name: string,
projectTitle: string,
reason?: string,
): EmailTemplate {
const greeting = name ? `Hi ${name},` : 'Hi there,'
const reasonHtml = reason
? paragraph(`<strong>Reason:</strong> ${escapeHtml(reason)}`)
: ''
const reasonText = reason ? `Reason: ${reason}\n\n` : ''
const content = `
${sectionTitle('Grand-Finale Slot Withdrawn')}
${paragraph(greeting)}
${infoBox(`We regret to inform you that your team's confirmed spot at the Grand Finale has been withdrawn for the project <strong>${escapeHtml(projectTitle)}</strong>.`, 'warning')}
${reasonHtml}
${paragraph('If you believe this is an error or have questions, please contact the MOPC team as soon as possible.')}
`
const text = [
greeting,
'',
`We regret to inform you that your team's confirmed spot at the Grand Finale has been withdrawn for the project "${projectTitle}".`,
'',
reasonText + 'If you believe this is an error or have questions, please contact the MOPC team as soon as possible.',
'',
'The MOPC team',
].join('\n')
return {
subject: 'Your grand-finale slot has been withdrawn',
html: getEmailWrapper(content),
text,
}
}
/**
* Travel confirmation email for a grand-finale attendee, including flight and hotel details.
*/
function getTravelConfirmedTemplate(
name: string,
projectTitle: string,
flight: {
arrivalAt?: Date | string | null
arrivalFlightNumber?: string | null
arrivalAirport?: string | null
departureAt?: Date | string | null
departureFlightNumber?: string | null
departureAirport?: string | null
},
hotel?: {
name: string
address?: string | null
link?: string | null
},
room?: {
roomNumber?: string | null
checkInAt?: string | null
checkOutAt?: string | null
},
): EmailTemplate {
const greeting = name ? `Hi ${name},` : 'Hi there,'
const fmtDate = (d: Date | string | null | undefined) => {
if (!d) return null
const dt = d instanceof Date ? d : new Date(d)
return dt.toLocaleString('en-GB', { timeZone: 'Europe/Paris', dateStyle: 'full', timeStyle: 'short' })
}
const arrivalLines: string[] = []
const departureLines: string[] = []
if (flight.arrivalAt) arrivalLines.push(`Date: ${fmtDate(flight.arrivalAt)} (Paris time)`)
if (flight.arrivalFlightNumber) arrivalLines.push(`Flight: ${flight.arrivalFlightNumber}`)
if (flight.arrivalAirport) arrivalLines.push(`Airport: ${flight.arrivalAirport}`)
if (flight.departureAt) departureLines.push(`Date: ${fmtDate(flight.departureAt)} (Paris time)`)
if (flight.departureFlightNumber) departureLines.push(`Flight: ${flight.departureFlightNumber}`)
if (flight.departureAirport) departureLines.push(`Airport: ${flight.departureAirport}`)
const arrivalHtml =
arrivalLines.length > 0
? `<h3 style="margin:20px 0 8px;color:#0f172a;font-size:14px;font-weight:600;text-transform:uppercase;letter-spacing:1px;">Arrival</h3>` +
`<ul style="margin:0 0 16px;padding-left:20px;color:${BRAND.textDark};font-size:14px;">` +
arrivalLines.map((l) => `<li style="margin:4px 0;">${escapeHtml(l)}</li>`).join('') +
`</ul>`
: ''
const departureHtml =
departureLines.length > 0
? `<h3 style="margin:20px 0 8px;color:#0f172a;font-size:14px;font-weight:600;text-transform:uppercase;letter-spacing:1px;">Departure</h3>` +
`<ul style="margin:0 0 16px;padding-left:20px;color:${BRAND.textDark};font-size:14px;">` +
departureLines.map((l) => `<li style="margin:4px 0;">${escapeHtml(l)}</li>`).join('') +
`</ul>`
: ''
const fmtDateShort = (d: string | null | undefined) => {
if (!d) return null
const dt = new Date(d)
return dt.toLocaleString('en-GB', { timeZone: 'Europe/Paris', dateStyle: 'long', timeStyle: 'short' })
}
const roomLines: string[] = []
if (room?.roomNumber) roomLines.push(`Room: ${room.roomNumber}`)
if (room?.checkInAt) roomLines.push(`Check-in: ${fmtDateShort(room.checkInAt)} (Paris time)`)
if (room?.checkOutAt) roomLines.push(`Check-out: ${fmtDateShort(room.checkOutAt)} (Paris time)`)
const roomHtml =
roomLines.length > 0
? `<ul style="margin:8px 0 0;padding-left:20px;color:${BRAND.textDark};font-size:14px;">` +
roomLines.map((l) => `<li style="margin:4px 0;">${escapeHtml(l)}</li>`).join('') +
`</ul>`
: ''
const hotelHtml = hotel
? `<h3 style="margin:20px 0 8px;color:#0f172a;font-size:14px;font-weight:600;text-transform:uppercase;letter-spacing:1px;">Hotel</h3>` +
infoBox(
`<strong>${escapeHtml(hotel.name)}</strong>` +
(hotel.address ? `<br>${escapeHtml(hotel.address)}` : '') +
(hotel.link ? `<br><a href="${hotel.link}" style="color:${BRAND.darkBlue};">View hotel</a>` : '') +
roomHtml,
'info',
)
: ''
const roomTextLines = roomLines.length > 0 ? '\n' + roomLines.map((l) => ` ${l}`).join('\n') : ''
const hotelText = hotel
? ['\nHotel:', ` ${hotel.name}`, ...(hotel.address ? [` ${hotel.address}`] : []), ...(hotel.link ? [` ${hotel.link}`] : []), roomTextLines].join('\n')
: ''
const content = `
${sectionTitle('Your travel is confirmed!')}
${paragraph(greeting)}
${paragraph(`Great news — your travel arrangements for the Grand Finale (<strong>${escapeHtml(projectTitle)}</strong>) have been confirmed. Here are your details:`)}
${arrivalHtml}
${departureHtml}
${hotelHtml}
${paragraph('If anything looks incorrect, please contact the MOPC team immediately.')}
`
const arrivalText = arrivalLines.length > 0 ? '\nArrival:\n' + arrivalLines.map((l) => ` ${l}`).join('\n') : ''
const departureText = departureLines.length > 0 ? '\nDeparture:\n' + departureLines.map((l) => ` ${l}`).join('\n') : ''
const text = [
greeting,
'',
`Great news — your travel arrangements for the Grand Finale ("${projectTitle}") have been confirmed. Here are your details:`,
arrivalText,
departureText,
hotelText,
'',
'If anything looks incorrect, please contact the MOPC team immediately.',
'',
'The MOPC team',
].join('\n')
return {
subject: 'Your grand-finale travel is confirmed',
html: getEmailWrapper(content),
text,
}
}
/**
* Visa status update email for a grand-finale attendee.
*/
function getVisaStatusTemplate(
name: string,
projectTitle: string,
status: 'INVITATION_SENT' | 'APPOINTMENT_BOOKED' | 'GRANTED' | 'DENIED',
note?: string,
): EmailTemplate {
const greeting = name ? `Hi ${name},` : 'Hi there,'
const statusCopy: Record<typeof status, { headline: string; body: string; variant: 'info' | 'success' | 'warning' }> = {
INVITATION_SENT: {
headline: 'Visa invitation letter sent',
body: 'Your official visa invitation letter for the Grand Finale has been sent. Please use it when applying for your visa at the relevant consulate or embassy.',
variant: 'info',
},
APPOINTMENT_BOOKED: {
headline: 'Visa appointment booked',
body: 'A visa appointment has been booked on your behalf. Please check your inbox or contact the MOPC team for the appointment details.',
variant: 'info',
},
GRANTED: {
headline: 'Visa granted — see you in Monaco!',
body: 'Congratulations — your visa has been granted. We look forward to welcoming you to the Monaco Ocean Protection Challenge Grand Finale.',
variant: 'success',
},
DENIED: {
headline: 'Visa application outcome',
body: 'Unfortunately, your visa application has not been successful at this time. Please contact the MOPC team as soon as possible so we can discuss next steps.',
variant: 'warning',
},
}
const { headline, body, variant } = statusCopy[status]
const noteHtml = note ? paragraph(`<em>${escapeHtml(note)}</em>`) : ''
const noteText = note ? `\n${note}\n` : ''
const content = `
${sectionTitle('Visa update for the Grand Finale')}
${paragraph(greeting)}
${paragraph(`This is an update regarding your visa for the Grand Finale — project <strong>${escapeHtml(projectTitle)}</strong>.`)}
${infoBox(`<strong>${escapeHtml(headline)}</strong><br>${escapeHtml(body)}`, variant)}
${noteHtml}
${paragraph('If you have any questions, please don\'t hesitate to contact the MOPC team.')}
`
const text = [
greeting,
'',
`This is an update regarding your visa for the Grand Finale — project "${projectTitle}".`,
'',
`${headline}`,
body,
noteText,
'If you have any questions, please don\'t hesitate to contact the MOPC team.',
'',
'The MOPC team',
].join('\n')
return {
subject: 'Visa update for the grand finale',
html: getEmailWrapper(content),
text,
}
}
/**
* Template registry mapping notification types to template generators
*/
@@ -2392,6 +2712,74 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
(ctx.metadata?.programName as string) || 'MOPC',
ctx.linkUrl
),
// Logistics templates (team/attendee-facing)
FINALIST_REMINDER: (ctx) =>
getFinalistReminderTemplate(
ctx.name || '',
(ctx.metadata?.projectTitle as string) || 'Your project',
new Date((ctx.metadata?.deadline as string) || Date.now()),
ctx.linkUrl || '',
),
GRAND_FINAL_DOCS_REMINDER: (ctx) =>
getGrandFinalDocsReminderTemplate(
ctx.name || '',
(ctx.metadata?.projectTitle as string) || 'Your project',
ctx.metadata?.deadline ? new Date(ctx.metadata.deadline as string) : null,
ctx.linkUrl || '',
(ctx.metadata?.missing as string[]) || [],
),
FINALIST_WITHDRAWN: (ctx) =>
getFinalistWithdrawnTemplate(
ctx.name || '',
(ctx.metadata?.projectTitle as string) || 'Your project',
ctx.metadata?.reason as string | undefined,
),
TRAVEL_CONFIRMED: (ctx) =>
getTravelConfirmedTemplate(
ctx.name || '',
(ctx.metadata?.projectTitle as string) || 'Your project',
{
arrivalAt: ctx.metadata?.arrivalAt as string | undefined,
arrivalFlightNumber: ctx.metadata?.arrivalFlightNumber as string | undefined,
arrivalAirport: ctx.metadata?.arrivalAirport as string | undefined,
departureAt: ctx.metadata?.departureAt as string | undefined,
departureFlightNumber: ctx.metadata?.departureFlightNumber as string | undefined,
departureAirport: ctx.metadata?.departureAirport as string | undefined,
},
ctx.metadata?.hotel as { name: string; address?: string; link?: string } | undefined,
ctx.metadata?.roomNumber !== undefined || ctx.metadata?.checkInAt !== undefined || ctx.metadata?.checkOutAt !== undefined
? {
roomNumber: ctx.metadata?.roomNumber as string | null | undefined,
checkInAt: ctx.metadata?.checkInAt as string | null | undefined,
checkOutAt: ctx.metadata?.checkOutAt as string | null | undefined,
}
: undefined,
),
VISA_STATUS_UPDATE: (ctx) =>
getVisaStatusTemplate(
ctx.name || '',
(ctx.metadata?.projectTitle as string) || 'Your project',
(ctx.metadata?.status as 'INVITATION_SENT' | 'APPOINTMENT_BOOKED' | 'GRANTED' | 'DENIED') || 'INVITATION_SENT',
ctx.metadata?.note as string | undefined,
),
}
/**
* Resolve the email template for a notification type without sending.
* Exported for use by preview endpoints.
*/
export function renderNotificationEmail(
name: string,
type: string,
context: NotificationEmailContext,
subjectOverride?: string,
): EmailTemplate {
const generator = NOTIFICATION_EMAIL_TEMPLATES[type]
const template = generator
? generator({ ...context, linkUrl: ensureAbsoluteUrl(context.linkUrl), name })
: getNotificationEmailTemplate(name, subjectOverride || context.title, context.message, ensureAbsoluteUrl(context.linkUrl))
return subjectOverride ? { ...template, subject: subjectOverride } : template
}
/**
@@ -2404,32 +2792,7 @@ export async function sendStyledNotificationEmail(
context: NotificationEmailContext,
subjectOverride?: string
): Promise<void> {
// Safety net: always ensure linkUrl is absolute before passing to templates
const safeContext = {
...context,
linkUrl: ensureAbsoluteUrl(context.linkUrl),
}
const templateGenerator = NOTIFICATION_EMAIL_TEMPLATES[type]
let template: EmailTemplate
if (templateGenerator) {
// Use styled template
template = templateGenerator({ ...safeContext, name })
// Apply subject override if provided
if (subjectOverride) {
template.subject = subjectOverride
}
} else {
// Fall back to generic template
template = getNotificationEmailTemplate(
name,
subjectOverride || safeContext.title,
safeContext.message,
safeContext.linkUrl
)
}
const template = renderNotificationEmail(name, type, context, subjectOverride)
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
}
@@ -2876,16 +3239,14 @@ export function getTeamMentorIntroductionTemplate(
'The MOPC team',
].join('\n')
const customNoteHtml = customNote
? `<div style="margin:0 0 16px;padding:12px 16px;background:#fff7ed;border-left:3px solid #de0f1e;border-radius:4px;color:#0f172a;">${escapeHtml(customNote)}</div>`
: ''
const noteHtml = customNote ? infoBox(escapeHtml(customNote), 'warning') : ''
const mentorHtmlList = mentors
.map(
(m) => `
<tr>
<td style="padding:6px 0;font-weight:600;color:#0f172a;">${escapeHtml(m.name ?? 'Mentor')}</td>
<td style="padding:6px 0;"><a href="mailto:${escapeHtml(m.email)}" style="color:#053d57;text-decoration:none;">${escapeHtml(m.email)}</a></td>
<td style="padding:6px 16px 6px 0;font-weight:600;color:#0f172a;vertical-align:top;">${escapeHtml(m.name ?? 'Mentor')}</td>
<td style="padding:6px 0;vertical-align:top;"><a href="mailto:${escapeHtml(m.email)}" style="color:#053d57;text-decoration:none;">${escapeHtml(m.email)}</a></td>
</tr>`,
)
.join('')
@@ -2894,55 +3255,41 @@ export function getTeamMentorIntroductionTemplate(
teammates && teammates.length > 0
? `
<h2 style="margin:24px 0 8px;color:#0f172a;font-size:15px;font-weight:600;">Your team</h2>
<table style="width:100%;border-collapse:collapse;margin:0 0 8px;font-size:14px;">${teammates
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="border-collapse:collapse;margin:0 0 8px;font-size:14px;">${teammates
.map(
(t) => `
<tr>
<td style="padding:6px 0;color:#0f172a;">${escapeHtml(t.name ?? 'Team member')}</td>
<td style="padding:6px 0;"><a href="mailto:${escapeHtml(t.email)}" style="color:#053d57;text-decoration:none;">${escapeHtml(t.email)}</a></td>
<td style="padding:6px 16px 6px 0;color:#0f172a;vertical-align:top;">${escapeHtml(t.name ?? 'Team member')}</td>
<td style="padding:6px 0;vertical-align:top;"><a href="mailto:${escapeHtml(t.email)}" style="color:#053d57;text-decoration:none;">${escapeHtml(t.email)}</a></td>
</tr>`,
)
.join('')}</table>`
: ''
const instructionsHtml = `
<div style="margin:16px 0;padding:12px 16px;background:#eff6ff;border-left:3px solid #3b82f6;border-radius:4px;">
<strong style="color:#1e40af;">Working with your mentor</strong>
<ul style="margin:8px 0 0 20px;padding:0;color:#0f172a;font-size:13px;">
<li style="margin:4px 0;">Go to the Mentoring section of your applicant portal to message your mentor directly they are notified by email when you write.</li>
<li style="margin:4px 0;">Share documents and questions early; your mentor is here to help you sharpen your project before the finals.</li>
<li style="margin:4px 0;">You can also email your mentor directly using the address above.</li>
</ul>
</div>`
const instructionsHtml = infoBox(
`<strong>Working with your mentor</strong><br>` +
`&bull; Go to the Mentoring section of your applicant portal to message your mentor directly — they are notified by email when you write.<br>` +
`&bull; Share documents and questions early; your mentor is here to help you sharpen your project before the finals.<br>` +
`&bull; You can also email your mentor directly using the address above.`,
'info',
)
const html = `
<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#0f172a;">
<div style="max-width:560px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
<div style="background:#053d57;padding:24px 28px;color:#fefefe;">
<h1 style="margin:0;font-size:20px;font-weight:600;">${count === 1 ? 'Meet your mentor' : `Meet your ${count} mentors`}</h1>
</div>
<div style="padding:24px 28px;line-height:1.5;font-size:14px;">
<p style="margin-top:0;">${recipientName ? `Hi ${escapeHtml(recipientName)},` : 'Hi there,'}</p>
${customNoteHtml}
<p>${count === 1
const content = `
${sectionTitle(count === 1 ? 'Meet your mentor' : `Meet your ${count} mentors`)}
${paragraph(recipientName ? `Hi ${escapeHtml(recipientName)},` : 'Hi there,')}
${noteHtml}
${paragraph(
count === 1
? `The mentoring round is now open and a mentor has been assigned to your project <strong>${escapeHtml(projectTitle)}</strong>:`
: `The mentoring round is now open and <strong>${count}</strong> mentors have been assigned to your project <strong>${escapeHtml(projectTitle)}</strong>:`}</p>
<table style="width:100%;border-collapse:collapse;margin:12px 0 8px;font-size:14px;">${mentorHtmlList}</table>
${teammatesHtml}
${instructionsHtml}
<p style="margin-top:24px;">
<a href="${workspaceUrl}" style="display:inline-block;padding:10px 20px;background:#de0f1e;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;">Open Mentor Workspace</a>
</p>
</div>
<div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
Monaco Ocean Protection Challenge
</div>
</div>
</body>
</html>
`.trim()
: `The mentoring round is now open and <strong>${count}</strong> mentors have been assigned to your project <strong>${escapeHtml(projectTitle)}</strong>:`,
)}
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="border-collapse:collapse;margin:8px 0;font-size:14px;">${mentorHtmlList}</table>
${teammatesHtml}
${instructionsHtml}
${ctaButton(workspaceUrl, 'Open Mentor Workspace')}
`
const html = getEmailWrapper(content)
return { subject, text, html }
}
@@ -3034,18 +3381,16 @@ export function getMentorBulkAssignmentTemplate(
'The MOPC team',
].join('\n')
const customNoteHtml = customNote
? `<div style="margin:0 0 16px;padding:12px 16px;background:#fff7ed;border-left:3px solid #de0f1e;border-radius:4px;color:#0f172a;">${escapeHtml(customNote)}</div>`
: ''
const noteHtml = customNote ? infoBox(escapeHtml(customNote), 'warning') : ''
const htmlList = projects
.map((p) => {
const members =
p.teamMembers && p.teamMembers.length > 0
? `<table style="width:100%;border-collapse:collapse;margin:6px 0 0;font-size:13px;">${p.teamMembers
? `<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="border-collapse:collapse;margin:6px 0 0;font-size:13px;">${p.teamMembers
.map(
(m) =>
`<tr><td style="padding:3px 0;color:#0f172a;">${escapeHtml(m.name ?? 'Team member')}</td><td style="padding:3px 0;"><a href="mailto:${escapeHtml(m.email)}" style="color:#053d57;text-decoration:none;">${escapeHtml(m.email)}</a></td></tr>`,
`<tr><td style="padding:3px 16px 3px 0;color:#0f172a;vertical-align:top;">${escapeHtml(m.name ?? 'Team member')}</td><td style="padding:3px 0;vertical-align:top;"><a href="mailto:${escapeHtml(m.email)}" style="color:#053d57;text-decoration:none;">${escapeHtml(m.email)}</a></td></tr>`,
)
.join('')}</table>`
: ''
@@ -3053,41 +3398,25 @@ export function getMentorBulkAssignmentTemplate(
})
.join('')
const instructionsHtml = `
<div style="margin:16px 0;padding:12px 16px;background:#eff6ff;border-left:3px solid #3b82f6;border-radius:4px;">
<strong style="color:#1e40af;">How to mentor on MOPC</strong>
<ul style="margin:8px 0 0 20px;padding:0;color:#0f172a;font-size:13px;">
<li style="margin:4px 0;">Open each project workspace from your Mentor Dashboard to chat with the team, share files, and track milestones.</li>
<li style="margin:4px 0;">Messages you send in the workspace notify the team by email automatically.</li>
<li style="margin:4px 0;">You can also email team members directly using the addresses listed above.</li>
</ul>
</div>`
const instructionsHtml = infoBox(
`<strong>How to mentor on MOPC</strong><br>` +
`&bull; Open each project workspace from your Mentor Dashboard to chat with the team, share files, and track milestones.<br>` +
`&bull; Messages you send in the workspace notify the team by email automatically.<br>` +
`&bull; You can also email team members directly using the addresses listed above.`,
'info',
)
const html = `
<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#0f172a;">
<div style="max-width:560px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
<div style="background:#053d57;padding:24px 28px;color:#fefefe;">
<h1 style="margin:0;font-size:20px;font-weight:600;">${count === 1 ? 'New mentor assignment' : `${count} new mentor assignments`}</h1>
</div>
<div style="padding:24px 28px;line-height:1.5;font-size:14px;">
<p style="margin-top:0;">${name ? `Hi ${escapeHtml(name)},` : 'Hi there,'}</p>
${customNoteHtml}
<p>${count === 1 ? 'You have been assigned as a mentor to a new project:' : `You have been assigned as a mentor to <strong>${count}</strong> new projects:`}</p>
<ul style="padding-left:20px;margin:12px 0 20px;">${htmlList}</ul>
${instructionsHtml}
<p style="margin-top:24px;">
<a href="${mentorDashboardUrl}" style="display:inline-block;padding:10px 20px;background:#de0f1e;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;">Open Mentor Dashboard</a>
</p>
</div>
<div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
Monaco Ocean Protection Challenge
</div>
</div>
</body>
</html>
`.trim()
const content = `
${sectionTitle(count === 1 ? 'New mentor assignment' : `${count} new mentor assignments`)}
${paragraph(name ? `Hi ${escapeHtml(name)},` : 'Hi there,')}
${noteHtml}
${paragraph(count === 1 ? 'You have been assigned as a mentor to a new project:' : `You have been assigned as a mentor to <strong>${count}</strong> new projects:`)}
<ul style="padding-left:20px;margin:12px 0 20px;color:${BRAND.textDark};font-size:15px;">${htmlList}</ul>
${instructionsHtml}
${ctaButton(mentorDashboardUrl, 'Open Mentor Dashboard')}
`
const html = getEmailWrapper(content)
return { subject, text, html }
}
@@ -3365,6 +3694,73 @@ ${opts.pickUrl}`
await sendEmail({ to: opts.to, subject, text, html })
}
/**
* Invite an external lunch attendee to choose their dish via a tokenized,
* no-login page. Used for the initial invite (auto on add + admin resend) and
* for reminder sweeps — one template serves both.
*/
export async function sendExternalDishInviteEmail(opts: {
to: string
name: string
eventAt: Date | null
venue: string | null
notes: string | null
changeDeadline: Date | null
pickUrl: string
}): Promise<void> {
const fmt = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Europe/Monaco', dateStyle: 'long', timeStyle: 'short',
})
const subject = 'Choose your lunch dish — Monaco Ocean Protection Challenge'
const venuePhrase = opts.venue
? `lunch at ${escapeHtml(opts.venue)}`
: 'the Monaco Ocean Protection Challenge lunch'
const eventLine = opts.eventAt
? `<strong>When:</strong> ${escapeHtml(fmt.format(opts.eventAt))} (Europe/Monaco)`
: ''
const notesLine = opts.notes ? escapeHtml(opts.notes) : ''
const content = `
${sectionTitle('Choose your lunch dish')}
${paragraph(`Hi ${escapeHtml(opts.name)},`)}
${paragraph(
`You are joining us for ${venuePhrase}. Please pick your dish and let us know ` +
'about any allergies so the kitchen can cater for you.',
)}
${eventLine ? paragraph(eventLine) : ''}
${notesLine ? paragraph(notesLine) : ''}
${
opts.changeDeadline
? infoBox(
`<strong>Please choose by ${escapeHtml(fmt.format(opts.changeDeadline))}.</strong>`,
'warning',
)
: ''
}
${ctaButton(opts.pickUrl, 'Choose my dish')}
${paragraph(
`<span style="color:#64748b;font-size:13px;">If you have any questions, reply to this email and we'll help.</span>`,
)}
`
const html = getEmailWrapper(content)
const text = [
`Choose your lunch dish — Monaco Ocean Protection Challenge`,
``,
`Hi ${opts.name},`,
``,
`Please pick your dish for ${
opts.venue ? `lunch at ${opts.venue}` : 'the Monaco Ocean Protection Challenge lunch'
}.`,
opts.eventAt ? `When: ${fmt.format(opts.eventAt)} (Europe/Monaco)` : '',
opts.notes ? opts.notes : '',
opts.changeDeadline ? `Please choose by: ${fmt.format(opts.changeDeadline)}` : '',
``,
`Choose your dish: ${opts.pickUrl}`,
]
.filter((l) => l !== '')
.join('\n')
await sendEmail({ to: opts.to, subject, text, html })
}
/**
* Send the lunch recap manifest to admins + extra recipients.
* Caller passes the assembled recap payload from `buildRecapPayload`.

View File

@@ -0,0 +1,45 @@
import { createHmac, timingSafeEqual } from 'crypto'
export type ExternalLunchTokenPayload = {
externalId: string
/** Unix seconds. Token is rejected after this. */
exp: number
}
function getSecret(): string {
const s = process.env.NEXTAUTH_SECRET
if (!s) throw new Error('NEXTAUTH_SECRET is not set; cannot sign external lunch tokens')
return s
}
function hmac(payloadB64: string): string {
return createHmac('sha256', getSecret()).update(payloadB64).digest('hex')
}
export function signExternalLunchToken(payload: ExternalLunchTokenPayload): string {
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url')
const sig = hmac(payloadB64)
return `${payloadB64}.${sig}`
}
export function verifyExternalLunchToken(token: string): ExternalLunchTokenPayload {
const parts = token.split('.')
if (parts.length !== 2) throw new Error('Invalid external lunch token: malformed')
const [payloadB64, sig] = parts
const expected = hmac(payloadB64)
const a = Buffer.from(sig, 'hex')
const b = Buffer.from(expected, 'hex')
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw new Error('Invalid external lunch token: signature mismatch')
}
let payload: ExternalLunchTokenPayload
try {
payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf-8'))
} catch {
throw new Error('Invalid external lunch token: payload not parseable')
}
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) {
throw new Error('Invalid external lunch token: expired')
}
return payload
}

54
src/lib/live-timer.ts Normal file
View File

@@ -0,0 +1,54 @@
/**
* Server-stamped phase timer math for the grand-finale ceremony.
*
* The cursor stores `phaseStartedAt` + `phaseDurationSeconds` plus a pause
* accumulator; every client derives the countdown locally from those stamps,
* so all screens agree and overtime is just a negative remainder.
*/
export type PhaseTimerState = {
phaseStartedAt: Date | string | null
phaseDurationSeconds: number | null
phasePausedAt: Date | string | null
phasePausedAccumMs: number
}
export function elapsedMs(t: PhaseTimerState, now: Date = new Date()): number {
if (!t.phaseStartedAt) return 0
const start = new Date(t.phaseStartedAt).getTime()
const end = t.phasePausedAt ? new Date(t.phasePausedAt).getTime() : now.getTime()
return Math.max(0, end - start - t.phasePausedAccumMs)
}
/** Seconds left on the phase timer; negative = overtime; null = no timer running. */
export function remainingSeconds(t: PhaseTimerState, now: Date = new Date()): number | null {
if (!t.phaseStartedAt || t.phaseDurationSeconds == null) return null
return t.phaseDurationSeconds - Math.floor(elapsedMs(t, now) / 1000)
}
/** `5:05` for positive seconds, `+1:23` for overtime. */
export function formatClock(seconds: number): string {
const over = seconds < 0
const abs = Math.abs(seconds)
const m = Math.floor(abs / 60)
const s = abs % 60
return `${over ? '+' : ''}${m}:${s.toString().padStart(2, '0')}`
}
/**
* Parse an admin duration input: `m:ss` / `mm:ss`, or plain minutes (`7`).
* Returns total seconds, or null for anything unparseable.
*/
export function parseClock(input: string): number | null {
const trimmed = input.trim()
if (!trimmed) return null
const colonMatch = /^(\d{1,3}):([0-5]\d)$/.exec(trimmed)
if (colonMatch) {
return parseInt(colonMatch[1], 10) * 60 + parseInt(colonMatch[2], 10)
}
const plainMatch = /^(\d{1,3})$/.exec(trimmed)
if (plainMatch) {
return parseInt(plainMatch[1], 10) * 60
}
return null
}

View File

@@ -46,6 +46,39 @@ export function checkRateLimit(
return { success: true, remaining: limit - entry.count, resetAt: entry.resetAt }
}
/**
* Ceremony-day procedures: cheap, read-mostly public endpoints polled by every
* screen in the venue. Hundreds of phones share one IP behind the venue NAT,
* so the standard 100/min-per-IP budget would 429 the audience mid-vote.
* Requests consisting ONLY of these procedures get a separate, much larger
* per-IP bucket. Vote integrity is enforced server-side (token + IP cap on
* AudienceFavoriteVote), not by the rate limiter.
*/
const CEREMONY_PROCEDURES = new Set([
'liveVoting.getCeremonyState',
'liveVoting.getAudienceWindow',
'liveVoting.getAudienceContextByRound',
'liveVoting.registerAudienceVoter',
'liveVoting.castFavoriteVote',
'liveVoting.getSessionForVotingByRound',
'liveVoting.getPublicResults',
'liveVoting.getAudienceSession',
'liveVoting.getSessionForVoting',
'live.getCursor',
'live.getMyNotes',
'live.saveNote',
'live.getMyCeremonyContext',
])
/** True when every (possibly batched) procedure in the tRPC URL is ceremony traffic. */
export function isCeremonyTraffic(pathname: string): boolean {
const path = pathname.replace(/^\/api\/trpc\/?/, '')
if (!path) return false
return path
.split(',')
.every((p) => CEREMONY_PROCEDURES.has(decodeURIComponent(p)))
}
// Clean up stale entries every 5 minutes to prevent memory leaks
if (typeof setInterval !== 'undefined') {
setInterval(() => {

View File

@@ -7,8 +7,9 @@ import { generateLogoKey, createStorageProvider, type StorageProviderType } from
import { getImageUploadUrl, confirmImageUpload, getImageUrl, deleteImage, type ImageUploadConfig } from '@/server/utils/image-upload'
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
import { logAudit } from '@/server/utils/audit'
import { createNotification } from '../services/in-app-notification'
import { createNotification, notifyProjectMentors, NotificationTypes } from '../services/in-app-notification'
import { checkRequirementsAndTransition, triggerInProgressOnActivity, transitionProject, isTerminalState } from '../services/round-engine'
import { getFinalDocumentStatusForProject, finalistUploadsEnabled } from '../services/final-documents'
import { EvaluationConfigSchema, MentoringConfigSchema } from '@/types/competition-configs'
import type { PrismaClient, Prisma, RoundType } from '@prisma/client'
@@ -328,18 +329,29 @@ export const applicantRouter = router({
})
}
// Fetch round info and verify it's active
// Fetch round info and verify uploads are open. Normally a round must be
// ROUND_ACTIVE; the grand-final documents are collected during the lead-up
// while the LIVE_FINAL round is still DRAFT (it only "opens" at the event),
// so allow uploads for a not-yet-closed LIVE_FINAL round too.
let roundName: string | undefined
if (input.roundId) {
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, status: true },
select: { name: true, status: true, roundType: true, finalizedAt: true, configJson: true },
})
if (round && round.status !== 'ROUND_ACTIVE') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'This round is closed. Documents can no longer be uploaded.',
})
if (round) {
const uploadable =
round.roundType === 'LIVE_FINAL'
? !round.finalizedAt &&
(round.status === 'ROUND_DRAFT' || round.status === 'ROUND_ACTIVE') &&
finalistUploadsEnabled(round.configJson)
: round.status === 'ROUND_ACTIVE'
if (!uploadable) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'This round is closed. Documents can no longer be uploaded.',
})
}
}
roundName = round?.name
}
@@ -498,6 +510,24 @@ export const applicantRouter = router({
console.warn('[DocAnalyzer] Post-upload analysis failed:', err))
).catch(() => {})
// Notify the team's mentor(s) when a Grand-Final document is uploaded.
if (input.roundId) {
try {
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { roundType: true } })
if (round?.roundType === 'LIVE_FINAL') {
await notifyProjectMentors(input.projectId, {
type: NotificationTypes.GRAND_FINAL_DOCS_SUBMITTED,
title: 'Final document uploaded',
message: `A team uploaded a Grand Final document: ${input.fileName}`,
linkUrl: `/mentor/workspace/${input.projectId}`,
metadata: { projectId: input.projectId, fileName: input.fileName },
})
}
} catch (e) {
console.error('[final-docs] mentor notify failed', e)
}
}
return file
}),
@@ -536,17 +566,26 @@ export const applicantRouter = router({
})
}
// Round-specific files can only be deleted while the round is active
// Round-specific files can only be modified while the round is open. As with
// upload, a not-yet-closed LIVE_FINAL round (DRAFT, pre-event) counts as open.
if (file.roundId) {
const round = await ctx.prisma.round.findUnique({
where: { id: file.roundId },
select: { status: true },
select: { status: true, roundType: true, finalizedAt: true, configJson: true },
})
if (round && round.status !== 'ROUND_ACTIVE') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'This round is closed. Documents can no longer be modified.',
})
if (round) {
const modifiable =
round.roundType === 'LIVE_FINAL'
? !round.finalizedAt &&
(round.status === 'ROUND_DRAFT' || round.status === 'ROUND_ACTIVE') &&
finalistUploadsEnabled(round.configJson)
: round.status === 'ROUND_ACTIVE'
if (!modifiable) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'This round is closed. Documents can no longer be modified.',
})
}
}
}
@@ -1423,7 +1462,14 @@ export const applicantRouter = router({
const allActiveRounds = await ctx.prisma.round.findMany({
where: {
competition: { programId },
status: 'ROUND_ACTIVE',
// Active rounds, plus the not-yet-opened (DRAFT) LIVE_FINAL round so
// enrolled finalists can upload their grand-final documents during the
// lead-up while the round itself stays "closed" until the event. The
// per-project membership filter below restricts this to teams in it.
OR: [
{ status: 'ROUND_ACTIVE' },
{ roundType: 'LIVE_FINAL', status: 'ROUND_DRAFT', finalizedAt: null },
],
},
orderBy: { sortOrder: 'asc' },
select: {
@@ -1432,6 +1478,7 @@ export const applicantRouter = router({
slug: true,
roundType: true,
windowCloseAt: true,
configJson: true,
specialAwardId: true,
specialAward: { select: { name: true } },
},
@@ -1450,6 +1497,9 @@ export const applicantRouter = router({
openRounds = allActiveRounds
.filter((r) => {
// LIVE_FINAL (grand-final documents) only shows to enrolled finalists,
// and only when the admin has enabled revised uploads.
if (r.roundType === 'LIVE_FINAL' && (!projectRoundIds.has(r.id) || !finalistUploadsEnabled(r.configJson))) return false
// Award round project isn't in → hide
if (r.specialAwardId && !projectRoundIds.has(r.id)) return false
// Main round when project is in award track and has no state in this round → hide
@@ -1521,6 +1571,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.
*/
@@ -2747,7 +2813,13 @@ export const applicantRouter = router({
*/
getMyFinalistConfirmation: protectedProcedure.query(async ({ ctx }) => {
const project = await ctx.prisma.project.findFirst({
where: { teamMembers: { some: { userId: ctx.user.id } } },
where: {
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
finalistConfirmation: { isNot: null },
},
include: {
program: { select: { id: true, defaultAttendeeCap: true } },
teamMembers: {
@@ -2858,4 +2930,136 @@ export const applicantRouter = router({
projectId: a.confirmation.project.id,
}))
}),
/**
* Returns logistics info for the caller's confirmed finalist project:
* hotel details, the caller's own flight detail, and visa visibility + status.
* Returns null when the caller has no confirmed finalist project.
*/
getMyLogistics: protectedProcedure.query(async ({ ctx }) => {
const project = await ctx.prisma.project.findFirst({
where: {
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
finalistConfirmation: { isNot: null },
},
include: {
program: {
select: { id: true, visaStatusVisibleToMembers: true },
},
finalistConfirmation: true,
},
orderBy: { createdAt: 'desc' },
})
if (!project || !project.finalistConfirmation) return null
if (project.finalistConfirmation.status !== 'CONFIRMED') return null
const programId = project.program.id
const confirmationId = project.finalistConfirmation.id
const visaVisible = project.program.visaStatusVisibleToMembers
// Caller's own AttendingMember + hotel stay + flight + visa
const attendee = await ctx.prisma.attendingMember.findFirst({
where: { confirmationId, userId: ctx.user.id },
include: {
hotelStay: { include: { hotel: true } },
flightDetail: true,
visaApplication: visaVisible,
},
})
const stay = attendee?.hotelStay ?? null
const hotel = stay?.hotel
? {
name: stay.hotel.name,
address: stay.hotel.address,
link: stay.hotel.link,
notes: stay.hotel.notes,
}
: null
const room = stay
? {
roomNumber: stay.roomNumber,
checkInAt: stay.checkInAt,
checkOutAt: stay.checkOutAt,
}
: null
const fd = attendee?.flightDetail ?? null
const myFlight = fd
? {
arrivalAt: fd.arrivalAt,
arrivalFlightNumber: fd.arrivalFlightNumber,
arrivalAirport: fd.arrivalAirport,
departureAt: fd.departureAt,
departureFlightNumber: fd.departureFlightNumber,
departureAirport: fd.departureAirport,
status: fd.status,
}
: null
const va = visaVisible ? (attendee?.visaApplication ?? null) : null
const myVisa = va ? { status: va.status, nationality: va.nationality } : null
return {
projectTitle: project.title,
confirmationStatus: project.finalistConfirmation.status,
hotel,
room,
myFlight,
visaVisible,
myVisa,
}
}),
/**
* Allows the authenticated user to self-declare their passport nationality
* on their own VisaApplication when visaStatusVisibleToMembers is true.
*/
updateMyVisaNationality: protectedProcedure
.input(z.object({ nationality: z.string().max(100) }))
.mutation(async ({ ctx, input }) => {
// Find the caller's AttendingMember whose program has visaStatusVisibleToMembers=true
// and which has a visaApplication.
const attendee = await ctx.prisma.attendingMember.findFirst({
where: {
userId: ctx.user.id,
confirmation: {
project: {
program: { visaStatusVisibleToMembers: true },
},
},
visaApplication: { isNot: null },
},
include: { visaApplication: true },
})
if (!attendee || !attendee.visaApplication) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No visa application to update',
})
}
const updated = await ctx.prisma.visaApplication.update({
where: { id: attendee.visaApplication.id },
data: { nationality: input.nationality },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'VISA_NATIONALITY_SELF_SET',
entityType: 'VisaApplication',
entityId: updated.id,
detailsJson: { nationality: input.nationality },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { ok: true }
}),
})

View File

@@ -402,6 +402,7 @@ export const applicationRouter = router({
email: data.contactEmail,
name: data.contactName,
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'ACTIVE',
phoneNumber: data.contactPhone,
},
@@ -474,6 +475,7 @@ export const applicationRouter = router({
email: member.email,
name: member.name,
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'NONE',
},
})
@@ -790,6 +792,7 @@ export const applicationRouter = router({
email: data.contactEmail,
name: data.contactName,
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'ACTIVE',
phoneNumber: data.contactPhone,
},

View File

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

View File

@@ -110,7 +110,6 @@ export const deliberationRouter = router({
.input(
z.object({
sessionId: z.string(),
juryMemberId: z.string(),
projectId: z.string(),
rank: z.number().int().min(1).optional(),
isWinnerPick: z.boolean().optional(),
@@ -118,15 +117,26 @@ export const deliberationRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
// Enforce that jury members can only vote as themselves
if (input.juryMemberId !== ctx.user.id) {
// Resolve the caller's participant row server-side: DeliberationParticipant
// and DeliberationVote both reference JuryGroupMember ids, which the
// client has no business knowing. A juror can only ever vote as themself.
const participant = await ctx.prisma.deliberationParticipant.findFirst({
where: {
sessionId: input.sessionId,
user: { userId: ctx.user.id },
},
})
if (!participant) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You can only submit votes as yourself',
message: 'You are not a participant in this deliberation',
})
}
const vote = await submitVote(input, ctx.prisma)
const vote = await submitVote(
{ ...input, juryMemberId: participant.userId },
ctx.prisma
)
await logAudit({
prisma: ctx.prisma,
@@ -310,6 +320,10 @@ export const deliberationRouter = router({
participants: {
select: { userId: true },
},
results: {
select: { projectId: true, finalRank: true, isAdminOverridden: true },
orderBy: { finalRank: 'asc' },
},
},
orderBy: { createdAt: 'desc' },
})

View File

@@ -9,11 +9,17 @@ import {
} from '../services/finalist-confirmation'
import {
createNotification,
notifyAdmins,
NotificationTypes,
} from '../services/in-app-notification'
import { sendFinalistConfirmationEmail } from '@/lib/email'
import { verifyFinalistToken } from '@/lib/finalist-token'
import { ensureLunchPickForAttendingMember } from '../services/lunch-pick-sync'
import {
resetOrCreatePendingConfirmation,
confirmAttendanceInTx,
} from '../services/finalist-enrollment'
import { sendManualFinalDocReminders, listFinalistDocumentsForReview, userCanReviewFinals, listReviewVisibilityOptions, reviewVisibleRequirementIds } from '../services/final-documents'
export const finalistRouter = router({
/** List all per-category finalist slot quotas for a program. */
@@ -294,6 +300,7 @@ export const finalistRouter = router({
project: {
select: {
id: true,
title: true,
programId: true,
program: { select: { defaultAttendeeCap: true } },
teamMembers: { select: { userId: true } },
@@ -370,6 +377,22 @@ export const finalistRouter = router({
attendingUserIds: input.attendingUserIds,
},
})
// Admin alert — best-effort, never throws
try {
await notifyAdmins({
type: NotificationTypes.FINALIST_CONFIRMED,
title: 'Finalist confirmed',
message: `"${confirmation.project.title}" (${confirmation.category}) has confirmed grand-finale attendance.`,
linkUrl: '/admin/logistics',
metadata: {
projectId: confirmation.projectId,
projectTitle: confirmation.project.title,
category: confirmation.category,
},
})
} catch (err) {
console.error('[finalist.confirm] failed to send admin notification:', err)
}
return { ok: true }
}),
@@ -386,7 +409,7 @@ export const finalistRouter = router({
const payload = verifyFinalistToken(input.token)
const confirmation = await ctx.prisma.finalistConfirmation.findUnique({
where: { id: payload.confirmationId },
include: { project: { select: { programId: true } } },
include: { project: { select: { programId: true, title: true } } },
})
if (!confirmation) throw new TRPCError({ code: 'NOT_FOUND' })
if (confirmation.token !== input.token) {
@@ -416,6 +439,22 @@ export const finalistRouter = router({
reason: input.reason ?? null,
},
})
// Admin alert — best-effort, never throws
try {
await notifyAdmins({
type: NotificationTypes.FINALIST_DECLINED,
title: 'Finalist declined',
message: `"${confirmation.project.title}" (${confirmation.category}) has declined grand-finale attendance.`,
linkUrl: '/admin/logistics',
metadata: {
projectId: confirmation.projectId,
projectTitle: confirmation.project.title,
category: confirmation.category,
},
})
} catch (err) {
console.error('[finalist.decline] failed to send admin notification:', err)
}
// Promote next waitlist entry in same category. windowHours pulled from
// the live grand-finale round in the program (LIVE_FINAL roundType).
@@ -490,36 +529,11 @@ export const finalistRouter = router({
}
await ctx.prisma.$transaction(async (tx) => {
await tx.finalistConfirmation.update({
where: { id: confirmation.id },
data: { status: 'CONFIRMED', confirmedAt: new Date() },
await confirmAttendanceInTx(tx, {
confirmationId: confirmation.id,
attendingUserIds: input.attendingUserIds,
visaFlags: input.visaFlags,
})
await tx.attendingMember.createMany({
data: input.attendingUserIds.map((userId) => ({
confirmationId: confirmation.id,
userId,
needsVisa: input.visaFlags[userId] ?? false,
})),
})
const visaUsers = input.attendingUserIds.filter(
(uid) => input.visaFlags[uid] === true,
)
if (visaUsers.length > 0) {
const created = await tx.attendingMember.findMany({
where: { confirmationId: confirmation.id, userId: { in: visaUsers } },
select: { id: true },
})
await tx.visaApplication.createMany({
data: created.map((m) => ({ attendingMemberId: m.id, status: 'REQUESTED' })),
})
}
const allMembers = await tx.attendingMember.findMany({
where: { confirmationId: confirmation.id, userId: { in: input.attendingUserIds } },
select: { id: true },
})
for (const m of allMembers) {
await ensureLunchPickForAttendingMember(tx, m.id)
}
})
await logAudit({
prisma: ctx.prisma,
@@ -546,7 +560,19 @@ export const finalistRouter = router({
.mutation(async ({ ctx, input }) => {
const confirmation = await ctx.prisma.finalistConfirmation.findUniqueOrThrow({
where: { id: input.confirmationId },
include: { project: { select: { programId: true } } },
include: {
project: {
select: {
programId: true,
title: true,
teamMembers: {
where: { role: 'LEAD' },
take: 1,
select: { userId: true },
},
},
},
},
})
if (confirmation.status !== 'PENDING') {
throw new TRPCError({
@@ -573,6 +599,41 @@ export const finalistRouter = router({
reason: input.reason ?? null,
},
})
// Admin alert — best-effort, never throws
try {
await notifyAdmins({
type: NotificationTypes.FINALIST_DECLINED,
title: 'Finalist declined (admin)',
message: `"${confirmation.project.title}" (${confirmation.category}) was declined by an admin.`,
linkUrl: '/admin/logistics',
metadata: {
projectId: confirmation.projectId,
projectTitle: confirmation.project.title,
category: confirmation.category,
},
})
} catch (err) {
console.error('[finalist.adminDecline] failed to send admin notification:', err)
}
// Withdrawal notification to team lead — best-effort, never throws
try {
const lead = confirmation.project.teamMembers[0]
if (lead) {
const projectTitle = confirmation.project.title
const reason = input.reason
await createNotification({
userId: lead.userId,
type: NotificationTypes.FINALIST_WITHDRAWN,
title: 'Grand finale slot withdrawn',
message: `Your team "${projectTitle}" is no longer a confirmed finalist.${reason ? ' Reason: ' + reason : ''}`,
linkUrl: '/applicant',
metadata: { projectTitle, reason },
})
}
} catch (err) {
console.error('[finalist.adminDecline] failed to send withdrawal notification to team:', err)
}
const round = await ctx.prisma.round.findFirst({
where: {
@@ -781,6 +842,11 @@ export const finalistRouter = router({
mentorId: true,
},
},
teamMembers: {
where: { role: 'LEAD' },
take: 1,
select: { userId: true },
},
},
},
},
@@ -839,6 +905,25 @@ export const finalistRouter = router({
cascadedAssignmentCount: activeAssignments.length,
},
})
// Withdrawal notification to team lead — best-effort, never throws
try {
const lead = confirmation.project.teamMembers[0]
if (lead) {
const projectTitle = confirmation.project.title
await createNotification({
userId: lead.userId,
type: NotificationTypes.FINALIST_WITHDRAWN,
title: 'Grand finale slot withdrawn',
message: `Your team "${projectTitle}" is no longer a confirmed finalist.`,
linkUrl: '/applicant',
metadata: { projectTitle },
})
}
} catch (err) {
console.error('[finalist.unconfirm] failed to send withdrawal notification to team:', err)
}
return { ok: true, cascadedMentorAssignment }
}),
@@ -929,6 +1014,23 @@ export const finalistRouter = router({
windowHours: input.windowHours,
},
})
// Admin alert — best-effort, never throws
try {
const projectTitle = project?.title ?? entry.projectId
await notifyAdmins({
type: NotificationTypes.FINALIST_WAITLIST_PROMOTED,
title: 'Waitlist entry promoted',
message: `"${projectTitle}" (${entry.category}) was manually promoted from the waitlist.`,
linkUrl: '/admin/logistics',
metadata: {
projectId: entry.projectId,
projectTitle,
category: entry.category,
},
})
} catch (err) {
console.error('[finalist.manualPromote] failed to send admin notification:', err)
}
return { confirmationId }
}),
@@ -1116,4 +1218,553 @@ export const finalistRouter = router({
return { ok: true }
}),
/**
* List all MENTORING-round projects as enrollment candidates, grouped by
* competitionCategory. Each candidate includes team members, inLiveFinal flag,
* confirmationStatus, and per-category quota + confirmed/pending counts.
* Drives the Finalist Enrollment Card on the LIVE_FINAL round Overview page.
*/
listEnrollmentCandidates: adminProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
// Resolve program (for attendeeCap) and its competitions' rounds
const program = await ctx.prisma.program.findUniqueOrThrow({
where: { id: input.programId },
select: { defaultAttendeeCap: true },
})
// Find the MENTORING and LIVE_FINAL rounds within this program's competitions
const rounds = await ctx.prisma.round.findMany({
where: {
competition: { programId: input.programId },
roundType: { in: ['MENTORING', 'LIVE_FINAL'] },
},
select: { id: true, roundType: true },
orderBy: { sortOrder: 'asc' },
})
const mentoringRound = rounds.find((r) => r.roundType === 'MENTORING') ?? null
const liveFinalRound = rounds.find((r) => r.roundType === 'LIVE_FINAL') ?? null
if (!mentoringRound) {
return {
liveFinalRoundId: liveFinalRound?.id ?? null,
attendeeCap: program.defaultAttendeeCap,
categories: [],
}
}
// Load all PRS in the MENTORING round with full project + team data
const states = await ctx.prisma.projectRoundState.findMany({
where: { roundId: mentoringRound.id },
select: {
project: {
select: {
id: true,
title: true,
teamName: true,
country: true,
competitionCategory: true,
finalistConfirmation: { select: { status: true } },
projectRoundStates: {
where: { roundId: liveFinalRound?.id ?? '' },
select: { projectId: true },
take: 1,
},
teamMembers: {
select: {
userId: true,
role: true,
user: { select: { name: true, email: true } },
},
},
},
},
},
orderBy: [{ project: { title: 'asc' } }],
})
// Aggregate confirmed/pending counts per category (mirror listCategoryCounts)
const grouped = await ctx.prisma.finalistConfirmation.groupBy({
by: ['category', 'status'],
where: { project: { programId: input.programId } },
_count: { _all: true },
})
const countsByCategory = new Map<string, { confirmed: number; pending: number }>()
for (const g of grouped) {
const slot = countsByCategory.get(g.category) ?? { confirmed: 0, pending: 0 }
if (g.status === 'CONFIRMED') slot.confirmed = g._count._all
if (g.status === 'PENDING') slot.pending = g._count._all
countsByCategory.set(g.category, slot)
}
// Load quotas for this program
const quotas = await ctx.prisma.finalistSlotQuota.findMany({
where: { programId: input.programId },
select: { category: true, quota: true },
})
const quotaByCategory = new Map(quotas.map((q) => [q.category as string, q.quota]))
// Group candidates by competitionCategory
const categoryMap = new Map<
string,
{
category: string
quota: number | null
confirmedCount: number
pendingCount: number
candidates: Array<{
projectId: string
title: string
teamName: string | null
country: string | null
inLiveFinal: boolean
confirmationStatus: string | null
teamMembers: Array<{ userId: string; name: string | null; role: string; email: string }>
}>
}
>()
for (const s of states) {
const p = s.project
const cat = (p.competitionCategory as string) ?? 'UNKNOWN'
if (!categoryMap.has(cat)) {
const counts = countsByCategory.get(cat) ?? { confirmed: 0, pending: 0 }
categoryMap.set(cat, {
category: cat,
quota: quotaByCategory.get(cat) ?? null,
confirmedCount: counts.confirmed,
pendingCount: counts.pending,
candidates: [],
})
}
const inLiveFinal = p.projectRoundStates.length > 0
categoryMap.get(cat)!.candidates.push({
projectId: p.id,
title: p.title,
teamName: p.teamName,
country: p.country,
inLiveFinal,
confirmationStatus: p.finalistConfirmation?.status ?? null,
teamMembers: p.teamMembers.map((tm) => ({
userId: tm.userId,
name: tm.user.name,
role: tm.role,
email: tm.user.email,
})),
})
}
return {
liveFinalRoundId: liveFinalRound?.id ?? null,
attendeeCap: program.defaultAttendeeCap,
categories: Array.from(categoryMap.values()),
}
}),
/**
* Unified finalist enrollment: advances a set of projects into the LIVE_FINAL
* round (creates ProjectRoundState, skipDuplicates) AND creates/resets their
* FinalistConfirmation in one atomic step.
*
* Two attendee modes per project:
* - EMAIL: sends the self-confirm link to the team lead (never throws in loop)
* - ADMIN_CONFIRM: validates + writes attendees immediately (CONFIRMED status)
*
* Returns { enrolled, emailed, adminConfirmed, skipped }.
*/
enrollFinalists: adminProcedure
.input(
z.object({
programId: z.string(),
roundId: z.string(), // the LIVE_FINAL round
enrollments: z
.array(
z.object({
projectId: z.string(),
mode: z.enum(['EMAIL', 'ADMIN_CONFIRM']),
attendingUserIds: z.array(z.string()).optional(),
visaFlags: z.record(z.string(), z.boolean()).optional(),
}),
)
.min(1),
}),
)
.mutation(async ({ ctx, input }) => {
// Resolve the LIVE_FINAL round + confirmationWindowHours. Validate the
// round belongs to the target program so an admin can't inject projects
// into another edition's round.
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: {
id: true,
configJson: true,
competition: { select: { programId: true } },
},
})
if (round.competition.programId !== input.programId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Round does not belong to this program',
})
}
const cfg = (round.configJson ?? {}) as { confirmationWindowHours?: number }
const windowHours = cfg.confirmationWindowHours ?? 24
// Validate all projects belong to this program
const projectIds = input.enrollments.map((e) => e.projectId)
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds }, programId: input.programId },
select: {
id: true,
title: true,
competitionCategory: true,
program: { select: { defaultAttendeeCap: true } },
teamMembers: {
select: {
userId: true,
role: true,
user: { select: { email: true, name: true } },
},
},
},
})
if (projects.length !== projectIds.length) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'One or more project IDs not found in this program',
})
}
const projectMap = new Map(projects.map((p) => [p.id, p]))
const baseUrl = (process.env.NEXTAUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '')
// Pre-validate every ADMIN_CONFIRM enrollment up front so a bad entry in
// a multi-team batch fails before any project is partially written.
for (const enrollment of input.enrollments) {
if (enrollment.mode !== 'ADMIN_CONFIRM') continue
const project = projectMap.get(enrollment.projectId)!
const cap = project.program.defaultAttendeeCap
const attendingUserIds = enrollment.attendingUserIds ?? []
if (attendingUserIds.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `ADMIN_CONFIRM mode requires attendingUserIds for project ${project.id}`,
})
}
if (attendingUserIds.length > cap) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Selection exceeds attendee cap of ${cap} for project ${project.id}`,
})
}
const teamUserIds = new Set(project.teamMembers.map((tm) => tm.userId))
for (const uid of attendingUserIds) {
if (!teamUserIds.has(uid)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `User ${uid} is not a team member of project ${project.id}`,
})
}
}
}
let enrolled = 0
let emailed = 0
let adminConfirmed = 0
const skipped: Array<{ projectId: string; reason: string }> = []
for (const enrollment of input.enrollments) {
const project = projectMap.get(enrollment.projectId)!
// Step 1: Create ProjectRoundState in LIVE_FINAL round (idempotent)
await ctx.prisma.projectRoundState.createMany({
data: [{ projectId: enrollment.projectId, roundId: input.roundId }],
skipDuplicates: true,
})
// Step 2: Create or reset the finalist confirmation
const category = project.competitionCategory as CompetitionCategory
const confirmResult = await resetOrCreatePendingConfirmation(ctx.prisma, {
projectId: enrollment.projectId,
category,
windowHours,
})
if (confirmResult.alreadyConfirmed) {
skipped.push({ projectId: enrollment.projectId, reason: 'ALREADY_CONFIRMED' })
enrolled++
continue
}
enrolled++
// Step 3: Mode-specific handling
if (enrollment.mode === 'EMAIL') {
// Send confirmation email to team lead (best-effort — never throw in loop)
const lead = project.teamMembers.find((tm) => tm.role === 'LEAD')?.user
if (lead?.email) {
const confirmUrl = `${baseUrl}/finalist/confirm/${confirmResult.token}`
try {
await sendFinalistConfirmationEmail(
lead.email,
lead.name ?? null,
project.title,
confirmResult.deadline,
confirmUrl,
)
emailed++
} catch (err) {
console.error(
`[finalist.enrollFinalists] failed to send email to ${lead.email} for project ${enrollment.projectId}:`,
err,
)
}
}
} else {
// ADMIN_CONFIRM: write attendees + visa + lunch rows immediately
const attendingUserIds = enrollment.attendingUserIds!
const visaFlags = enrollment.visaFlags ?? {}
await ctx.prisma.$transaction(async (tx) => {
await confirmAttendanceInTx(tx, {
confirmationId: confirmResult.id,
attendingUserIds,
visaFlags,
})
})
adminConfirmed++
}
// Step 4: Audit per enrollment
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'FINALIST_ENROLL',
entityType: 'Project',
entityId: enrollment.projectId,
detailsJson: {
projectId: enrollment.projectId,
mode: enrollment.mode,
roundId: input.roundId,
programId: input.programId,
},
})
}
return { enrolled, emailed, adminConfirmed, skipped }
}),
/**
* Reverse enrollment: removes a project from the LIVE_FINAL round and
* deletes its FinalistConfirmation (cascade removes AttendingMember,
* FlightDetail, VisaApplication, and MemberLunchPick rows).
*
* Mentor assignments (tied to the MENTORING round) are intentionally
* left untouched. Safe to call even if the project was never enrolled
* (deleteMany is a no-op when no rows match).
*/
unenroll: adminProcedure
.input(
z.object({
projectId: z.string(),
roundId: z.string(), // the LIVE_FINAL round
}),
)
.mutation(async ({ ctx, input }) => {
// Guard: the project and round must belong to the same program, so a
// mismatched (projectId, roundId) pair from different editions can't be
// used to delete the wrong project's confirmation or round membership.
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
select: { programId: true },
})
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { competition: { select: { programId: true } } },
})
if (project.programId !== round.competition.programId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Project and round belong to different programs',
})
}
// Step 1: Capture the CONFIRMED confirmation (if any) BEFORE deleting,
// so we can notify the team lead. We only notify when the team had
// already confirmed (CONFIRMED status) — not for PENDING or absent rows.
const confirmedConfirmation = await ctx.prisma.finalistConfirmation.findFirst({
where: { projectId: input.projectId, status: 'CONFIRMED' },
select: {
project: {
select: {
title: true,
teamMembers: {
where: { role: 'LEAD' },
take: 1,
select: { userId: true },
},
},
},
},
})
// Step 2: Delete the FinalistConfirmation (cascade removes AttendingMember
// / FlightDetail / VisaApplication / MemberLunchPick).
// deleteMany is no-op-safe when no row exists.
await ctx.prisma.finalistConfirmation.deleteMany({
where: { projectId: input.projectId },
})
// Step 3: Delete the LIVE_FINAL ProjectRoundState.
await ctx.prisma.projectRoundState.deleteMany({
where: { projectId: input.projectId, roundId: input.roundId },
})
// Step 4: Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'FINALIST_UNENROLL',
entityType: 'Project',
entityId: input.projectId,
detailsJson: {
projectId: input.projectId,
roundId: input.roundId,
},
})
// Step 5: Withdrawal notification — only when a CONFIRMED row existed.
// Best-effort, never throws.
if (confirmedConfirmation) {
try {
const lead = confirmedConfirmation.project.teamMembers[0]
if (lead) {
const projectTitle = confirmedConfirmation.project.title
await createNotification({
userId: lead.userId,
type: NotificationTypes.FINALIST_WITHDRAWN,
title: 'Grand finale slot withdrawn',
message: `Your team "${projectTitle}" is no longer a confirmed finalist.`,
linkUrl: '/applicant',
metadata: { projectTitle },
})
}
} catch (err) {
console.error('[finalist.unenroll] failed to send withdrawal notification to team:', err)
}
}
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). */
/** Lightweight boolean — may the current user open the finalist documents review? Self-resolves the active program so the nav can gate the link without fetching the full payload. */
canReviewDocuments: protectedProcedure.query(async ({ ctx }) => {
const program = await ctx.prisma.program.findFirst({
where: { status: 'ACTIVE' },
orderBy: { year: 'desc' },
select: { id: true },
})
if (!program) return false
return userCanReviewFinals(ctx.prisma, ctx.user.id, ctx.user.role, program.id)
}),
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)
}),
/** Read whether finalists may upload revised grand-final documents (admin toggle). */
getRevisedUploadSetting: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true } })
const cfg = (round?.configJson ?? {}) as { allowFinalistRevisedUploads?: boolean }
return { enabled: !!cfg.allowFinalistRevisedUploads }
}),
/** Toggle whether finalists may upload revised grand-final documents (admin setting on the LIVE_FINAL round). */
setRevisedUploadSetting: adminProcedure
.input(z.object({ roundId: z.string(), enabled: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true, roundType: true } })
if (!round || round.roundType !== 'LIVE_FINAL') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not a grand-final round' })
}
const cfg = (round.configJson ?? {}) as Record<string, unknown>
await ctx.prisma.round.update({
where: { id: input.roundId },
data: { configJson: { ...cfg, allowFinalistRevisedUploads: input.enabled } },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'FINALIST_REVISED_UPLOADS_TOGGLED',
entityType: 'Round',
entityId: input.roundId,
detailsJson: { enabled: input.enabled },
})
return { ok: true, enabled: input.enabled }
}),
/** Options + current selection for the "documents shown to judges" picker. */
getReviewDocSettings: adminProcedure
.input(z.object({ programId: z.string(), roundId: z.string() }))
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true } })
return {
options: await listReviewVisibilityOptions(ctx.prisma, input.programId),
selectedIds: reviewVisibleRequirementIds(round?.configJson ?? null),
}
}),
/** Set which prior-round documents finale judges see. null = show all (clears curation). */
setReviewVisibleRequirements: adminProcedure
.input(z.object({ roundId: z.string(), requirementIds: z.array(z.string()).nullable() }))
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true, roundType: true } })
if (!round || round.roundType !== 'LIVE_FINAL') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not a grand-final round' })
}
const { reviewVisibleRequirementIds: _omit, ...rest } = (round.configJson ?? {}) as Record<string, unknown>
const next = input.requirementIds === null ? rest : { ...rest, reviewVisibleRequirementIds: input.requirementIds }
await ctx.prisma.round.update({ where: { id: input.roundId }, data: { configJson: next as object } })
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'FINALIST_REVIEW_DOCS_CURATED',
entityType: 'Round',
entityId: input.roundId,
detailsJson: { requirementIds: input.requirementIds },
})
return { ok: true }
}),
})

View File

@@ -440,6 +440,7 @@ export const juryGroupRouter = router({
email: invitee.email,
name: invitee.name || null,
role: 'JURY_MEMBER',
roles: ['JURY_MEMBER'],
status: 'INVITED',
inviteToken,
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),

View File

@@ -1,6 +1,7 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { randomUUID } from 'crypto'
import type { PrismaClient } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, publicProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
interface LiveVotingCriterion {
@@ -11,6 +12,114 @@ interface LiveVotingCriterion {
weight: number
}
// ─── Grand-finale audience favorite-vote windows ─────────────────────────────
const windowKeySchema = z.enum(['CATEGORY:STARTUP', 'CATEGORY:BUSINESS_CONCEPT', 'OVERALL'])
const revealStepSchema = z.object({
kind: z.enum(['category-intro', 'place', 'audience-award', 'overall-favorite', 'thanks']),
category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
place: z.number().int().min(1).max(10).optional(),
projectId: z.string().optional(),
title: z.string().max(200).optional(),
subtitle: z.string().max(300).optional(),
})
const MAX_FAVORITE_VOTERS_PER_IP = 3
/** Server-side window check — the source of truth even if no one closed the window. */
function windowIsOpen(
s: { audiencePhase: string; audienceWindowClosesAt: Date | null },
now = new Date()
) {
return s.audiencePhase === 'OPEN' && !!s.audienceWindowClosesAt && now <= s.audienceWindowClosesAt
}
function categoryForKey(key: string): 'STARTUP' | 'BUSINESS_CONCEPT' | null {
if (key === 'CATEGORY:STARTUP') return 'STARTUP'
if (key === 'CATEGORY:BUSINESS_CONCEPT') return 'BUSINESS_CONCEPT'
return null
}
/**
* Finale project order: the cursor system's round.configJson.projectOrder is
* the source of truth; session.projectOrderJson is the fallback.
*/
async function getOrderedFinaleProjects(
prisma: PrismaClient,
session: { roundId: string | null; projectOrderJson: unknown }
) {
let order: string[] = []
if (session.roundId) {
const round = await prisma.round.findUnique({ where: { id: session.roundId } })
order = (((round?.configJson as Record<string, unknown>) ?? {}).projectOrder as string[]) ?? []
}
if (order.length === 0) order = (session.projectOrderJson as string[]) ?? []
if (order.length === 0) return []
const projects = await prisma.project.findMany({
where: { id: { in: order } },
select: { id: true, title: true, teamName: true, competitionCategory: true },
})
const byId = new Map(projects.map((p) => [p.id, p]))
return order.map((id) => byId.get(id)).filter((p): p is NonNullable<typeof p> => !!p)
}
/** Shared jury-voting payload for getSessionForVoting / getSessionForVotingByRound. */
async function buildVotingPayload(
prisma: PrismaClient,
session: {
id: string
status: string
currentProjectId: string | null
votingStartedAt: Date | null
votingEndsAt: Date | null
votingMode: string
criteriaJson: unknown
round: unknown
},
userId: string
) {
let currentProject = null
if (session.currentProjectId && session.status === 'IN_PROGRESS') {
currentProject = await prisma.project.findUnique({
where: { id: session.currentProjectId },
select: { id: true, title: true, teamName: true, description: true },
})
}
let userVote = null
if (session.currentProjectId) {
userVote = await prisma.liveVote.findFirst({
where: {
sessionId: session.id,
projectId: session.currentProjectId,
userId,
},
})
}
let timeRemaining = null
if (session.votingEndsAt && session.status === 'IN_PROGRESS') {
const remaining = new Date(session.votingEndsAt).getTime() - Date.now()
timeRemaining = Math.max(0, Math.floor(remaining / 1000))
}
return {
session: {
id: session.id,
status: session.status,
votingStartedAt: session.votingStartedAt,
votingEndsAt: session.votingEndsAt,
votingMode: session.votingMode,
criteriaJson: session.criteriaJson,
},
round: session.round,
currentProject,
userVote,
timeRemaining,
}
}
export const liveVotingRouter = router({
/**
* Get or create a live voting session for a round
@@ -97,46 +206,63 @@ export const liveVotingRouter = router({
},
},
})
return buildVotingPayload(ctx.prisma, session, ctx.user.id)
}),
let currentProject = null
if (session.currentProjectId && session.status === 'IN_PROGRESS') {
currentProject = await ctx.prisma.project.findUnique({
where: { id: session.currentProjectId },
select: { id: true, title: true, teamName: true, description: true },
})
}
let userVote = null
if (session.currentProjectId) {
userVote = await ctx.prisma.liveVote.findFirst({
where: {
sessionId: session.id,
projectId: session.currentProjectId,
userId: ctx.user.id,
/**
* Same payload, resolved from a roundId (the jury live page only knows the
* round). Returns null — and creates nothing — when no session exists yet.
*/
getSessionForVotingByRound: protectedProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUnique({
where: { roundId: input.roundId },
include: {
round: {
include: {
competition: {
include: {
program: { select: { name: true, year: true } },
},
},
},
},
})
}
let timeRemaining = null
if (session.votingEndsAt && session.status === 'IN_PROGRESS') {
const remaining = new Date(session.votingEndsAt).getTime() - Date.now()
timeRemaining = Math.max(0, Math.floor(remaining / 1000))
}
return {
session: {
id: session.id,
status: session.status,
votingStartedAt: session.votingStartedAt,
votingEndsAt: session.votingEndsAt,
votingMode: session.votingMode,
criteriaJson: session.criteriaJson,
},
round: session.round,
currentProject,
userVote,
timeRemaining,
}
})
if (!session) return null
return buildVotingPayload(ctx.prisma, session, ctx.user.id)
}),
/**
* A juror's own finale inputs (votes incl. comments + ceremony notes) for a
* round — resurfaced during deliberation so they can review or revise.
*/
getMyFinaleInputs: protectedProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUnique({
where: { roundId: input.roundId },
select: { id: true, status: true, votingMode: true, criteriaJson: true },
})
const [votes, notes] = await Promise.all([
session
? ctx.prisma.liveVote.findMany({
where: { sessionId: session.id, userId: ctx.user.id },
select: {
projectId: true,
score: true,
criterionScoresJson: true,
comment: true,
votedAt: true,
},
})
: Promise.resolve([]),
ctx.prisma.liveNote.findMany({
where: { roundId: input.roundId, userId: ctx.user.id },
}),
])
return { session: session ?? null, votes, notes }
}),
/**
@@ -447,6 +573,7 @@ export const liveVotingRouter = router({
criterionScores: z
.record(z.string(), z.number())
.optional(),
comment: z.string().max(5000).optional(),
})
)
.mutation(async ({ ctx, input }) => {
@@ -489,22 +616,29 @@ export const liveVotingRouter = router({
}
}
if (session.status !== 'IN_PROGRESS') {
if (session.status !== 'IN_PROGRESS' && session.status !== 'PAUSED') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Voting is not currently active',
})
}
if (session.currentProjectId !== input.projectId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot vote for this project right now',
})
const isCurrentProject = session.currentProjectId === input.projectId
if (!isCurrentProject) {
// Revision path (deliberation / catching up): any project in the
// finale run order may be (re)scored while the session is open.
const ordered = await getOrderedFinaleProjects(ctx.prisma, session)
if (!ordered.some((p) => p.id === input.projectId)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot vote for this project right now',
})
}
}
// Check if voting window is still open
if (session.votingEndsAt && new Date() > session.votingEndsAt) {
// The timed voting window only applies to the live flow for the
// currently presented project
if (isCurrentProject && session.votingEndsAt && new Date() > session.votingEndsAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Voting window has closed',
@@ -568,11 +702,14 @@ export const liveVotingRouter = router({
score: finalScore,
isAudienceVote: false,
criterionScoresJson: criterionScoresJson ?? undefined,
comment: input.comment ?? undefined,
},
update: {
score: finalScore,
isAudienceVote: false,
criterionScoresJson: criterionScoresJson ?? undefined,
// An omitted comment leaves the existing one untouched
...(input.comment !== undefined ? { comment: input.comment } : {}),
votedAt: new Date(),
},
})
@@ -759,6 +896,7 @@ export const liveVotingRouter = router({
audienceMaxFavorites: z.number().int().min(1).max(20).optional(),
audienceRequireId: z.boolean().optional(),
audienceVotingDuration: z.number().int().min(1).max(600).nullable().optional(),
allowOverallFavorite: z.boolean().optional(),
})
)
.mutation(async ({ ctx, input }) => {
@@ -917,6 +1055,506 @@ export const liveVotingRouter = router({
return vote
}),
// ───────────────────────────────────────────────────────────────────────────
// Grand-finale audience favorite-vote windows
// ───────────────────────────────────────────────────────────────────────────
/**
* Open an audience voting window ("favorite Startup", "favorite Business
* Concept", or — if enabled — "overall favorite") for a fixed duration.
*/
openAudienceWindow: adminProcedure
.input(
z.object({
sessionId: z.string(),
windowKey: windowKeySchema,
durationMinutes: z.number().int().min(1).max(120).default(5),
})
)
.mutation(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
})
if (windowIsOpen(session)) {
throw new TRPCError({
code: 'CONFLICT',
message: 'An audience voting window is already open — close it first',
})
}
if (input.windowKey === 'OVERALL' && !session.allowOverallFavorite) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'The overall favorite vote is not enabled for this session',
})
}
const now = new Date()
const updated = await ctx.prisma.liveVotingSession.update({
where: { id: input.sessionId },
data: {
audiencePhase: 'OPEN',
audienceWindowKey: input.windowKey,
audienceWindowOpenedAt: now,
audienceWindowClosesAt: new Date(now.getTime() + input.durationMinutes * 60_000),
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'AUDIENCE_WINDOW_OPENED',
entityType: 'LiveVotingSession',
entityId: input.sessionId,
detailsJson: { windowKey: input.windowKey, durationMinutes: input.durationMinutes },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Close the audience voting window early (allowed at any time).
*/
closeAudienceWindow: adminProcedure
.input(z.object({ sessionId: z.string() }))
.mutation(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
})
const updated = await ctx.prisma.liveVotingSession.update({
where: { id: input.sessionId },
data: {
audiencePhase: 'CLOSED',
audienceWindowKey: null,
audienceWindowOpenedAt: null,
audienceWindowClosesAt: null,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'AUDIENCE_WINDOW_CLOSED',
entityType: 'LiveVotingSession',
entityId: input.sessionId,
detailsJson: { windowKey: session.audienceWindowKey },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Public window state for the audience voting page: open/closed, eligible
* projects (in run order), closing time, and the caller's current pick.
*/
getAudienceWindow: publicProcedure
.input(z.object({ sessionId: z.string(), token: z.string().optional() }))
.query(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
select: {
id: true,
roundId: true,
projectOrderJson: true,
audiencePhase: true,
audienceWindowKey: true,
audienceWindowClosesAt: true,
allowAudienceVotes: true,
},
})
const open = windowIsOpen(session)
const windowKey = open ? session.audienceWindowKey : null
let projects: Awaited<ReturnType<typeof getOrderedFinaleProjects>> = []
if (open && windowKey) {
const cat = categoryForKey(windowKey)
const ordered = await getOrderedFinaleProjects(ctx.prisma, session)
projects = cat ? ordered.filter((p) => p.competitionCategory === cat) : ordered
}
let myVoteProjectId: string | null = null
if (input.token && windowKey) {
const voter = await ctx.prisma.audienceVoter.findUnique({
where: { token: input.token },
})
if (voter && voter.sessionId === input.sessionId) {
const existing = await ctx.prisma.audienceFavoriteVote.findUnique({
where: {
sessionId_windowKey_audienceVoterId: {
sessionId: input.sessionId,
windowKey,
audienceVoterId: voter.id,
},
},
})
myVoteProjectId = existing?.projectId ?? null
}
}
return {
open,
windowKey,
closesAt: open ? session.audienceWindowClosesAt : null,
projects,
myVoteProjectId,
}
}),
/**
* Cast (or change) a pick-one-favorite vote in the open window.
* Gates, in order: token, window open, time, eligibility, category, IP cap.
*/
castFavoriteVote: publicProcedure
.input(z.object({ sessionId: z.string(), token: z.string(), projectId: z.string() }))
.mutation(async ({ ctx, input }) => {
const voter = await ctx.prisma.audienceVoter.findUnique({
where: { token: input.token },
})
if (!voter || voter.sessionId !== input.sessionId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid voting token' })
}
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
})
if (!windowIsOpen(session) || !session.audienceWindowKey) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Voting is not open right now',
})
}
const windowKey = session.audienceWindowKey
const ordered = await getOrderedFinaleProjects(ctx.prisma, session)
const project = ordered.find((p) => p.id === input.projectId)
if (!project) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Project is not part of this vote' })
}
const cat = categoryForKey(windowKey)
if (cat && project.competitionCategory !== cat) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Project is not in the category currently open for voting',
})
}
const existing = await ctx.prisma.audienceFavoriteVote.findUnique({
where: {
sessionId_windowKey_audienceVoterId: {
sessionId: input.sessionId,
windowKey,
audienceVoterId: voter.id,
},
},
})
// IP cap only gates NEW voters — an existing voter may always update.
if (!existing && ctx.ip) {
const ipCount = await ctx.prisma.audienceFavoriteVote.count({
where: { sessionId: input.sessionId, windowKey, ipAddress: ctx.ip },
})
if (ipCount >= MAX_FAVORITE_VOTERS_PER_IP) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: 'Vote limit reached for this network connection',
})
}
}
const vote = await ctx.prisma.audienceFavoriteVote.upsert({
where: {
sessionId_windowKey_audienceVoterId: {
sessionId: input.sessionId,
windowKey,
audienceVoterId: voter.id,
},
},
create: {
sessionId: input.sessionId,
windowKey,
projectId: input.projectId,
audienceVoterId: voter.id,
ipAddress: ctx.ip ?? null,
},
update: {
projectId: input.projectId,
ipAddress: ctx.ip ?? existing?.ipAddress ?? null,
},
})
return { projectId: vote.projectId, windowKey }
}),
/**
* Per-window per-project favorite-vote tallies (admin only — the big screen
* shows only the total count).
*/
getFavoriteTallies: adminProcedure
.input(z.object({ sessionId: z.string() }))
.query(async ({ ctx, input }) => {
const grouped = await ctx.prisma.audienceFavoriteVote.groupBy({
by: ['windowKey', 'projectId'],
where: { sessionId: input.sessionId },
_count: { _all: true },
})
const projectIds = [...new Set(grouped.map((g) => g.projectId))]
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds } },
select: { id: true, title: true, teamName: true },
})
const byId = new Map(projects.map((p) => [p.id, p]))
const windowKeys = [...new Set(grouped.map((g) => g.windowKey))]
const windows = windowKeys.map((windowKey) => {
const rows = grouped.filter((g) => g.windowKey === windowKey)
return {
windowKey,
totalVotes: rows.reduce((sum, r) => sum + r._count._all, 0),
projects: rows
.map((r) => ({
projectId: r.projectId,
title: byId.get(r.projectId)?.title ?? 'Unknown',
teamName: byId.get(r.projectId)?.teamName ?? null,
count: r._count._all,
}))
.sort((a, b) => b.count - a.count),
}
})
return { windows }
}),
/**
* Resolve the live voting session for a round (public — the audience page
* only knows the roundId from the QR code URL).
*/
getAudienceContextByRound: publicProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUnique({
where: { roundId: input.roundId },
select: {
id: true,
allowAudienceVotes: true,
round: {
select: {
name: true,
competition: {
select: { program: { select: { name: true, year: true } } },
},
},
},
},
})
if (!session) return null
return {
sessionId: session.id,
allowAudienceVotes: session.allowAudienceVotes,
roundName: session.round?.name ?? null,
programName: session.round?.competition?.program?.name ?? null,
}
}),
// ───────────────────────────────────────────────────────────────────────────
// Results reveal controller (big screen)
// ───────────────────────────────────────────────────────────────────────────
/**
* Save (or replace) the reveal step list as a private DRAFT.
* Display strings are denormalized into the steps at compose time so the
* public endpoint needs no joins.
*/
saveReveal: adminProcedure
.input(
z.object({
sessionId: z.string(),
steps: z.array(revealStepSchema).max(50),
})
)
.mutation(async ({ ctx, input }) => {
return ctx.prisma.revealState.upsert({
where: { sessionId: input.sessionId },
create: {
sessionId: input.sessionId,
stepsJson: input.steps,
status: 'DRAFT',
currentStepIndex: -1,
},
update: { stepsJson: input.steps, status: 'DRAFT', currentStepIndex: -1 },
})
}),
/**
* Arm the reveal: the big screen switches to the Results splash, nothing
* is revealed yet.
*/
armReveal: adminProcedure
.input(z.object({ sessionId: z.string() }))
.mutation(async ({ ctx, input }) => {
const reveal = await ctx.prisma.revealState.findUniqueOrThrow({
where: { sessionId: input.sessionId },
})
const steps = (reveal.stepsJson as unknown[]) ?? []
if (steps.length === 0) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'No reveal steps composed' })
}
const updated = await ctx.prisma.revealState.update({
where: { sessionId: input.sessionId },
data: { status: 'ARMED', currentStepIndex: -1 },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'REVEAL_ARMED',
entityType: 'RevealState',
entityId: updated.id,
detailsJson: { stepCount: steps.length },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Reveal the next step. Clamps at the final step and flips to DONE.
*/
revealNext: adminProcedure
.input(z.object({ sessionId: z.string() }))
.mutation(async ({ ctx, input }) => {
const reveal = await ctx.prisma.revealState.findUniqueOrThrow({
where: { sessionId: input.sessionId },
})
if (reveal.status !== 'ARMED' && reveal.status !== 'REVEALING' && reveal.status !== 'DONE') {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Reveal is not armed' })
}
const steps = (reveal.stepsJson as unknown[]) ?? []
const newIndex = Math.min(reveal.currentStepIndex + 1, steps.length - 1)
const updated = await ctx.prisma.revealState.update({
where: { sessionId: input.sessionId },
data: {
currentStepIndex: newIndex,
status: newIndex >= steps.length - 1 ? 'DONE' : 'REVEALING',
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'REVEAL_ADVANCED',
entityType: 'RevealState',
entityId: updated.id,
detailsJson: { stepIndex: newIndex },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Reset the reveal to DRAFT — the big screen leaves reveal mode.
*/
resetReveal: adminProcedure
.input(z.object({ sessionId: z.string() }))
.mutation(async ({ ctx, input }) => {
const updated = await ctx.prisma.revealState.update({
where: { sessionId: input.sessionId },
data: { status: 'DRAFT', currentStepIndex: -1 },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'REVEAL_RESET',
entityType: 'RevealState',
entityId: updated.id,
detailsJson: {},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/** Full reveal state incl. all steps — admin preview only. */
getRevealAdmin: adminProcedure
.input(z.object({ sessionId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.revealState.findUnique({ where: { sessionId: input.sessionId } })
}),
/**
* Everything the big-screen ceremony view needs, derived from existing
* state. Public. NEVER includes scores or un-revealed steps.
*/
getCeremonyState: publicProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const [cursor, session] = await Promise.all([
ctx.prisma.liveProgressCursor.findUnique({ where: { roundId: input.roundId } }),
ctx.prisma.liveVotingSession.findUnique({
where: { roundId: input.roundId },
select: {
id: true,
audiencePhase: true,
audienceWindowKey: true,
audienceWindowClosesAt: true,
round: {
select: {
name: true,
competition: {
select: { program: { select: { name: true, year: true } } },
},
},
},
},
}),
])
let activeProject = null
if (cursor?.activeProjectId) {
activeProject = await ctx.prisma.project.findUnique({
where: { id: cursor.activeProjectId },
select: { id: true, title: true, teamName: true, competitionCategory: true },
})
}
const audienceOpen = session ? windowIsOpen(session) : false
let voteCount = 0
if (session && audienceOpen && session.audienceWindowKey) {
voteCount = await ctx.prisma.audienceFavoriteVote.count({
where: { sessionId: session.id, windowKey: session.audienceWindowKey },
})
}
let reveal: { status: string; currentStepIndex: number; steps: unknown[] } | null = null
if (session) {
const revealState = await ctx.prisma.revealState.findUnique({
where: { sessionId: session.id },
})
if (revealState && revealState.status !== 'DRAFT') {
const steps = (revealState.stepsJson as unknown[]) ?? []
reveal = {
status: revealState.status,
currentStepIndex: revealState.currentStepIndex,
// ONLY steps revealed so far — never the full list
steps:
revealState.status === 'ARMED' ? [] : steps.slice(0, revealState.currentStepIndex + 1),
}
}
}
return {
overrideSlide: cursor?.overrideSlide ?? null,
phase: cursor
? {
projectPhase: cursor.projectPhase,
phaseStartedAt: cursor.phaseStartedAt,
phaseDurationSeconds: cursor.phaseDurationSeconds,
phasePausedAt: cursor.phasePausedAt,
phasePausedAccumMs: cursor.phasePausedAccumMs,
}
: null,
activeProject,
audience: {
open: audienceOpen,
windowKey: audienceOpen ? session?.audienceWindowKey ?? null : null,
closesAt: audienceOpen ? session?.audienceWindowClosesAt ?? null : null,
voteCount,
},
reveal,
programName: session?.round?.competition?.program?.name ?? null,
roundName: session?.round?.name ?? null,
}
}),
/**
* Get audience voter stats (admin)
*/

View File

@@ -1,9 +1,66 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { Prisma, type PrismaClient, type LiveProgressCursor } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, audienceProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
// ─── Grand-finale phase machine helpers ─────────────────────────────────────
type TimingEntry = {
projectId: string
phase: 'PRESENTING' | 'QA'
startedAt: string
endedAt: string
configuredSeconds: number | null
elapsedSeconds: number // pause-adjusted actual duration
overranSeconds: number
}
/**
* When leaving a timed phase (PRESENTING/QA), compute the actual elapsed time
* (pause-adjusted) and append it to the cursor's timing log. Overtime is
* recorded as fact — never as a penalty.
*/
function closedOutTiming(cursor: LiveProgressCursor, now: Date): Prisma.InputJsonValue | undefined {
if (!cursor.phaseStartedAt || !cursor.activeProjectId) return undefined
if (cursor.projectPhase !== 'PRESENTING' && cursor.projectPhase !== 'QA') return undefined
const end = cursor.phasePausedAt ?? now
const elapsedSec = Math.max(
0,
Math.floor((end.getTime() - cursor.phaseStartedAt.getTime() - cursor.phasePausedAccumMs) / 1000)
)
const entry: TimingEntry = {
projectId: cursor.activeProjectId,
phase: cursor.projectPhase,
startedAt: cursor.phaseStartedAt.toISOString(),
endedAt: now.toISOString(),
configuredSeconds: cursor.phaseDurationSeconds,
elapsedSeconds: elapsedSec,
overranSeconds:
cursor.phaseDurationSeconds == null
? 0
: Math.max(0, elapsedSec - cursor.phaseDurationSeconds),
}
const log = Array.isArray(cursor.timingLogJson) ? (cursor.timingLogJson as unknown as TimingEntry[]) : []
return [...log, entry] as unknown as Prisma.InputJsonValue
}
type ProjectTimingOverride = { presentationSeconds?: number; qaSeconds?: number }
async function getRoundCeremonyConfig(prisma: PrismaClient, roundId: string) {
const round = await prisma.round.findUniqueOrThrow({ where: { id: roundId } })
const cfg = (round.configJson as Record<string, unknown>) ?? {}
return {
presentationSeconds:
typeof cfg.presentationDurationMinutes === 'number'
? cfg.presentationDurationMinutes * 60
: 300,
qaSeconds: typeof cfg.qaDurationMinutes === 'number' ? cfg.qaDurationMinutes * 60 : 300,
projectOrder: (cfg.projectOrder as string[]) ?? [],
timingOverrides: (cfg.projectTimingOverrides as Record<string, ProjectTimingOverride>) ?? {},
}
}
export const liveRouter = router({
/**
* Start a live presentation session for a stage
@@ -72,6 +129,13 @@ export const liveRouter = router({
},
})
// Keep the voting session in lockstep from the very start so jury
// votes target the active project (a session may not exist yet).
await tx.liveVotingSession.updateMany({
where: { roundId: input.roundId },
data: { currentProjectId: input.projectOrder[0], status: 'IN_PROGRESS' },
})
return created
})
@@ -344,6 +408,410 @@ export const liveRouter = router({
return updated
}),
// ───────────────────────────────────────────────────────────────────────────
// Grand-finale phase machine (ON_DECK → PRESENTING → QA → SCORING)
// ───────────────────────────────────────────────────────────────────────────
/**
* Put a project on every screen as "Up next" — the grace period before the
* presentation actually starts. Clears any timer and override slide.
*/
sendToScreens: adminProcedure
.input(z.object({ roundId: z.string(), projectId: z.string() }))
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { roundId: input.roundId },
})
const { projectOrder } = await getRoundCeremonyConfig(ctx.prisma, input.roundId)
const index = projectOrder.indexOf(input.projectId)
if (index === -1) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Project is not in the session order' })
}
const now = new Date()
const updated = await ctx.prisma.liveProgressCursor.update({
where: { id: cursor.id },
data: {
activeProjectId: input.projectId,
activeOrderIndex: index,
projectPhase: 'ON_DECK',
phaseStartedAt: null,
phaseDurationSeconds: null,
phasePausedAt: null,
phasePausedAccumMs: 0,
overrideSlide: null,
...(closedOutTiming(cursor, now) !== undefined
? { timingLogJson: closedOutTiming(cursor, now) }
: {}),
},
})
// Keep the voting session in lockstep so jury votes target this project
// (updateMany: a session may not exist yet — that's fine).
await ctx.prisma.liveVotingSession.updateMany({
where: { roundId: input.roundId },
data: { currentProjectId: input.projectId, status: 'IN_PROGRESS' },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_SEND_TO_SCREENS',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: { projectId: input.projectId, orderIndex: index },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Start the presentation timer for the on-deck project.
*/
startPresentation: adminProcedure
.input(
z.object({
roundId: z.string(),
durationSeconds: z.number().int().min(10).max(7200).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { roundId: input.roundId },
})
if (!cursor.activeProjectId) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'No project is on screen' })
}
const cfg = await getRoundCeremonyConfig(ctx.prisma, input.roundId)
const projectOverride = cfg.timingOverrides[cursor.activeProjectId]
const now = new Date()
const updated = await ctx.prisma.liveProgressCursor.update({
where: { id: cursor.id },
data: {
projectPhase: 'PRESENTING',
phaseStartedAt: now,
phaseDurationSeconds:
input.durationSeconds ?? projectOverride?.presentationSeconds ?? cfg.presentationSeconds,
phasePausedAt: null,
phasePausedAccumMs: 0,
...(closedOutTiming(cursor, now) !== undefined
? { timingLogJson: closedOutTiming(cursor, now) }
: {}),
},
})
// Sync the voting session even when the admin skips sendToScreens
await ctx.prisma.liveVotingSession.updateMany({
where: { roundId: input.roundId },
data: { currentProjectId: cursor.activeProjectId, status: 'IN_PROGRESS' },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_PHASE_STARTED',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: { phase: 'PRESENTING', projectId: cursor.activeProjectId, durationSeconds: updated.phaseDurationSeconds },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Close out the presentation (logging overtime) and start the Q&A timer.
*/
startQA: adminProcedure
.input(
z.object({
roundId: z.string(),
durationSeconds: z.number().int().min(10).max(7200).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { roundId: input.roundId },
})
if (!cursor.activeProjectId) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'No project is on screen' })
}
const cfg = await getRoundCeremonyConfig(ctx.prisma, input.roundId)
const projectOverride = cfg.timingOverrides[cursor.activeProjectId]
const now = new Date()
const updated = await ctx.prisma.liveProgressCursor.update({
where: { id: cursor.id },
data: {
projectPhase: 'QA',
phaseStartedAt: now,
phaseDurationSeconds: input.durationSeconds ?? projectOverride?.qaSeconds ?? cfg.qaSeconds,
phasePausedAt: null,
phasePausedAccumMs: 0,
...(closedOutTiming(cursor, now) !== undefined
? { timingLogJson: closedOutTiming(cursor, now) }
: {}),
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_PHASE_STARTED',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: { phase: 'QA', projectId: cursor.activeProjectId, durationSeconds: updated.phaseDurationSeconds },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Close out Q&A (logging overtime) and open jury scoring (no timer).
*/
openScoring: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { roundId: input.roundId },
})
const now = new Date()
const updated = await ctx.prisma.liveProgressCursor.update({
where: { id: cursor.id },
data: {
projectPhase: 'SCORING',
phaseStartedAt: null,
phaseDurationSeconds: null,
phasePausedAt: null,
phasePausedAccumMs: 0,
...(closedOutTiming(cursor, now) !== undefined
? { timingLogJson: closedOutTiming(cursor, now) }
: {}),
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_PHASE_STARTED',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: { phase: 'SCORING', projectId: cursor.activeProjectId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Pause the running phase timer (e.g. technical difficulties).
*/
pausePhase: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { roundId: input.roundId },
})
if (!cursor.phaseStartedAt || cursor.phasePausedAt) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: cursor.phasePausedAt ? 'Timer is already paused' : 'No timer is running',
})
}
const updated = await ctx.prisma.liveProgressCursor.update({
where: { id: cursor.id },
data: { phasePausedAt: new Date() },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_PHASE_PAUSED',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: { phase: cursor.projectPhase, projectId: cursor.activeProjectId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Resume the paused phase timer, folding paused time into the accumulator.
*/
resumePhase: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { roundId: input.roundId },
})
if (!cursor.phasePausedAt) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Timer is not paused' })
}
const now = new Date()
const updated = await ctx.prisma.liveProgressCursor.update({
where: { id: cursor.id },
data: {
phasePausedAccumMs:
cursor.phasePausedAccumMs + (now.getTime() - cursor.phasePausedAt.getTime()),
phasePausedAt: null,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_PHASE_RESUMED',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: { phase: cursor.projectPhase, projectId: cursor.activeProjectId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Per-project presentation/Q&A durations. Precedence at phase start:
* explicit durationSeconds input > this override > round config default.
* Passing null clears a field.
*/
setProjectTiming: adminProcedure
.input(
z.object({
roundId: z.string(),
projectId: z.string(),
presentationSeconds: z.number().int().min(10).max(7200).nullable().optional(),
qaSeconds: z.number().int().min(10).max(7200).nullable().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
const cfg = ((round.configJson as Record<string, unknown>) ?? {}) as Record<string, unknown>
const overrides = { ...((cfg.projectTimingOverrides as Record<string, ProjectTimingOverride>) ?? {}) }
const entry: ProjectTimingOverride = { ...(overrides[input.projectId] ?? {}) }
if (input.presentationSeconds !== undefined) {
if (input.presentationSeconds === null) delete entry.presentationSeconds
else entry.presentationSeconds = input.presentationSeconds
}
if (input.qaSeconds !== undefined) {
if (input.qaSeconds === null) delete entry.qaSeconds
else entry.qaSeconds = input.qaSeconds
}
if (Object.keys(entry).length === 0) delete overrides[input.projectId]
else overrides[input.projectId] = entry
await ctx.prisma.round.update({
where: { id: input.roundId },
data: {
configJson: { ...cfg, projectTimingOverrides: overrides } as Prisma.InputJsonValue,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_PROJECT_TIMING_SET',
entityType: 'Round',
entityId: input.roundId,
detailsJson: { projectId: input.projectId, ...entry },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { projectId: input.projectId, ...entry }
}),
/**
* Force a static slide on the big screen (or clear it).
*/
setOverrideSlide: adminProcedure
.input(
z.object({
roundId: z.string(),
slide: z.enum(['welcome', 'break', 'deliberation', 'thanks']).nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { roundId: input.roundId },
})
const updated = await ctx.prisma.liveProgressCursor.update({
where: { id: cursor.id },
data: { overrideSlide: input.slide },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_OVERRIDE_SLIDE',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: { slide: input.slide },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Persisted per-juror per-project ceremony notes (autosaved by the UI;
* resurfaced during deliberation).
*/
saveNote: protectedProcedure
.input(
z.object({
roundId: z.string(),
projectId: z.string(),
content: z.string().max(20_000),
})
)
.mutation(async ({ ctx, input }) => {
return ctx.prisma.liveNote.upsert({
where: {
roundId_projectId_userId: {
roundId: input.roundId,
projectId: input.projectId,
userId: ctx.user.id,
},
},
create: {
roundId: input.roundId,
projectId: input.projectId,
userId: ctx.user.id,
content: input.content,
},
update: { content: input.content },
})
}),
getMyNotes: protectedProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.liveNote.findMany({
where: { roundId: input.roundId, userId: ctx.user.id },
orderBy: { updatedAt: 'desc' },
})
}),
/**
* Ceremony navigation context for jurors: the active live round (if a
* ceremony cursor exists) and any deliberation sessions they participate in.
*/
getMyCeremonyContext: protectedProcedure.query(async ({ ctx }) => {
const cursor = await ctx.prisma.liveProgressCursor.findFirst({
where: { round: { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE' } },
select: { roundId: true },
orderBy: { updatedAt: 'desc' },
})
const participations = await ctx.prisma.deliberationParticipant.findMany({
where: {
user: { userId: ctx.user.id },
session: { status: { in: ['DELIB_OPEN', 'VOTING', 'RUNOFF'] } },
},
select: {
session: { select: { id: true, category: true, status: true } },
},
})
return {
liveRoundId: cursor?.roundId ?? null,
deliberations: participations.map((p) => p.session),
}
}),
/**
* Get current cursor state (for all users, including audience)
*/
@@ -376,10 +844,22 @@ export const liveRouter = router({
teamName: true,
description: true,
tags: true,
competitionCategory: true,
},
})
}
// Full run order with categories (for the admin run-order list and
// category grouping on every surface)
const orderProjects = await ctx.prisma.project.findMany({
where: { id: { in: projectOrder } },
select: { id: true, title: true, teamName: true, competitionCategory: true },
})
const orderById = new Map(orderProjects.map((p) => [p.id, p]))
const orderedProjects = projectOrder
.map((id) => orderById.get(id))
.filter((p): p is NonNullable<typeof p> => !!p)
// Get open cohorts for this round (if any)
const openCohorts = await ctx.prisma.cohort.findMany({
where: { roundId: input.roundId, isOpen: true },
@@ -395,6 +875,9 @@ export const liveRouter = router({
...cursor,
activeProject,
projectOrder,
orderedProjects,
projectTimingOverrides:
((config.projectTimingOverrides as Record<string, { presentationSeconds?: number; qaSeconds?: number }>) ?? {}),
totalProjects: projectOrder.length,
openCohorts,
}

View File

@@ -3,17 +3,26 @@ import { FlightDetailStatus, VisaStatus } from '@prisma/client'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
import { createNotification, NotificationTypes } from '../services/in-app-notification'
export const logisticsRouter = router({
/** Read the hotel for a program (1:1). Null if not yet set. */
getHotel: adminProcedure
// ─────────────────────────────────────────────────────────────────────────
// Hotels CRUD (multi-hotel per edition)
// ─────────────────────────────────────────────────────────────────────────
/** List all hotels for an edition, including occupancy count. */
listHotels: adminProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.hotel.findUnique({ where: { programId: input.programId } })
return ctx.prisma.hotel.findMany({
where: { programId: input.programId },
include: { _count: { select: { stays: true } } },
orderBy: { name: 'asc' },
})
}),
/** Create or update the program's hotel. Empty link strings are stored as null. */
upsertHotel: adminProcedure
/** Create a new hotel for the edition. Empty link strings are stored as null. */
createHotel: adminProcedure
.input(
z.object({
programId: z.string(),
@@ -29,26 +38,19 @@ export const logisticsRouter = router({
)
.mutation(async ({ ctx, input }) => {
const link = input.link && input.link.trim().length > 0 ? input.link : null
const hotel = await ctx.prisma.hotel.upsert({
where: { programId: input.programId },
create: {
const hotel = await ctx.prisma.hotel.create({
data: {
programId: input.programId,
name: input.name,
address: input.address ?? null,
link,
notes: input.notes ?? null,
},
update: {
name: input.name,
address: input.address ?? null,
link,
notes: input.notes ?? null,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'HOTEL_UPSERT',
action: 'HOTEL_CREATE',
entityType: 'Hotel',
entityId: hotel.id,
detailsJson: { programId: input.programId, name: input.name },
@@ -56,6 +58,301 @@ export const logisticsRouter = router({
return hotel
}),
/** Update an existing hotel's fields. Empty link strings are stored as null. */
updateHotel: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).max(200),
address: z.string().max(500).optional().nullable(),
link: z
.string()
.url()
.or(z.literal(''))
.optional()
.nullable(),
notes: z.string().max(2000).optional().nullable(),
}),
)
.mutation(async ({ ctx, input }) => {
const link =
input.link !== undefined
? input.link && input.link.trim().length > 0
? input.link
: null
: undefined
const hotel = await ctx.prisma.hotel.update({
where: { id: input.id },
data: {
name: input.name,
...(input.address !== undefined ? { address: input.address } : {}),
...(link !== undefined ? { link } : {}),
...(input.notes !== undefined ? { notes: input.notes } : {}),
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'HOTEL_UPDATE',
entityType: 'Hotel',
entityId: hotel.id,
detailsJson: { id: input.id, name: input.name },
})
return hotel
}),
/**
* Delete a hotel. Rejected with BAD_REQUEST if any HotelStay rows reference it
* (the user must reassign occupants first).
*/
deleteHotel: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const occupantCount = await ctx.prisma.hotelStay.count({
where: { hotelId: input.id },
})
if (occupantCount > 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Reassign ${occupantCount} occupant(s) before deleting this hotel.`,
})
}
const hotel = await ctx.prisma.hotel.delete({ where: { id: input.id } })
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'HOTEL_DELETE',
entityType: 'Hotel',
entityId: hotel.id,
detailsJson: { id: input.id, name: hotel.name },
})
return { success: true }
}),
// ─────────────────────────────────────────────────────────────────────────
// Rooming — per-attendee hotel/room assignment
// ─────────────────────────────────────────────────────────────────────────
/**
* One row per CONFIRMED AttendingMember in the program.
* Each row includes their hotelStay (null when not yet assigned).
* Sorted by project title then user name.
*/
listRooming: adminProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const rows = await ctx.prisma.attendingMember.findMany({
where: {
confirmation: {
status: 'CONFIRMED',
project: { programId: input.programId },
},
},
select: {
id: true,
user: { select: { id: true, name: true, email: true } },
confirmation: {
select: {
id: true,
project: { select: { id: true, title: true } },
},
},
hotelStay: {
select: { hotelId: true, roomNumber: true, checkInAt: true, checkOutAt: true },
},
},
orderBy: [
{ confirmation: { project: { title: 'asc' } } },
{ user: { name: 'asc' } },
],
})
return rows.map((r) => ({
attendingMemberId: r.id,
confirmationId: r.confirmation.id,
projectId: r.confirmation.project.id,
projectTitle: r.confirmation.project.title,
user: r.user,
stay: r.hotelStay ?? null,
}))
}),
/**
* Upsert an individual attendee's hotel/room assignment.
* Validates that the hotel belongs to the same program as the attendee.
*/
assignStay: adminProcedure
.input(
z.object({
attendingMemberId: z.string(),
hotelId: z.string(),
roomNumber: z.string().max(50).optional().nullable(),
checkInAt: z.date().optional().nullable(),
checkOutAt: z.date().optional().nullable(),
notes: z.string().max(2000).optional().nullable(),
}),
)
.mutation(async ({ ctx, input }) => {
// Validate hotel belongs to the same program as the attendee
const [attendee, hotel] = await Promise.all([
ctx.prisma.attendingMember.findUnique({
where: { id: input.attendingMemberId },
select: {
confirmation: { select: { project: { select: { programId: true } } } },
},
}),
ctx.prisma.hotel.findUnique({
where: { id: input.hotelId },
select: { programId: true },
}),
])
if (!attendee || !hotel) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Attendee or hotel not found.' })
}
if (attendee.confirmation.project.programId !== hotel.programId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Hotel does not belong to the same program as the attendee.',
})
}
const data: Record<string, unknown> = {
hotelId: input.hotelId,
}
if (input.roomNumber !== undefined) data.roomNumber = input.roomNumber
if (input.checkInAt !== undefined) data.checkInAt = input.checkInAt
if (input.checkOutAt !== undefined) data.checkOutAt = input.checkOutAt
if (input.notes !== undefined) data.notes = input.notes
const stay = await ctx.prisma.hotelStay.upsert({
where: { attendingMemberId: input.attendingMemberId },
create: {
attendingMemberId: input.attendingMemberId,
hotelId: input.hotelId,
roomNumber: input.roomNumber ?? null,
checkInAt: input.checkInAt ?? null,
checkOutAt: input.checkOutAt ?? null,
notes: input.notes ?? null,
},
update: data,
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'HOTEL_STAY_ASSIGN',
entityType: 'HotelStay',
entityId: stay.id,
detailsJson: { attendingMemberId: input.attendingMemberId, hotelId: input.hotelId },
})
return stay
}),
/**
* Assign all AttendingMembers of a confirmation to a hotel (the "whole team" shortcut).
* Preserves each member's existing roomNumber on update; sets it to null on create.
* Validates the hotel belongs to the same program as the confirmation's project.
*/
assignTeamToHotel: adminProcedure
.input(
z.object({
confirmationId: z.string(),
hotelId: z.string(),
checkInAt: z.date().optional().nullable(),
checkOutAt: z.date().optional().nullable(),
}),
)
.mutation(async ({ ctx, input }) => {
// Validate program match
const [confirmation, hotel] = await Promise.all([
ctx.prisma.finalistConfirmation.findUnique({
where: { id: input.confirmationId },
select: {
project: { select: { programId: true } },
attendingMembers: { select: { id: true } },
},
}),
ctx.prisma.hotel.findUnique({
where: { id: input.hotelId },
select: { programId: true },
}),
])
if (!confirmation || !hotel) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Confirmation or hotel not found.' })
}
if (confirmation.project.programId !== hotel.programId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Hotel does not belong to the same program as this confirmation.',
})
}
// Upsert each attendee's stay, preserving existing roomNumber
const results = await Promise.all(
confirmation.attendingMembers.map(async (member) => {
const existing = await ctx.prisma.hotelStay.findUnique({
where: { attendingMemberId: member.id },
select: { roomNumber: true },
})
return ctx.prisma.hotelStay.upsert({
where: { attendingMemberId: member.id },
create: {
attendingMemberId: member.id,
hotelId: input.hotelId,
roomNumber: null,
checkInAt: input.checkInAt ?? null,
checkOutAt: input.checkOutAt ?? null,
},
update: {
hotelId: input.hotelId,
...(input.checkInAt !== undefined ? { checkInAt: input.checkInAt } : {}),
...(input.checkOutAt !== undefined ? { checkOutAt: input.checkOutAt } : {}),
// Preserve existing roomNumber (do not overwrite it)
...(existing?.roomNumber !== undefined ? {} : {}),
},
})
}),
)
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'HOTEL_TEAM_ASSIGN',
entityType: 'FinalistConfirmation',
entityId: input.confirmationId,
detailsJson: {
confirmationId: input.confirmationId,
hotelId: input.hotelId,
count: results.length,
},
})
return { count: results.length }
}),
/** Remove an attendee's hotel/room assignment. No-op safe (deleteMany). */
unassignStay: adminProcedure
.input(z.object({ attendingMemberId: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.hotelStay.deleteMany({
where: { attendingMemberId: input.attendingMemberId },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'HOTEL_STAY_UNASSIGN',
entityType: 'HotelStay',
entityId: input.attendingMemberId,
detailsJson: { attendingMemberId: input.attendingMemberId },
})
return { success: true }
}),
// ─────────────────────────────────────────────────────────────────────────
// Confirmations
// ─────────────────────────────────────────────────────────────────────────
/**
* Read-only listing of every FinalistConfirmation in a program, with the
* joined project + attendee count + decline reason. Sorted by status
@@ -103,6 +400,10 @@ export const logisticsRouter = router({
})
}),
// ─────────────────────────────────────────────────────────────────────────
// Flight details
// ─────────────────────────────────────────────────────────────────────────
/**
* List all attending members for CONFIRMED finalists in a program, with
* their (optional) flight details. One row per attendee — even those
@@ -161,6 +462,12 @@ export const logisticsRouter = router({
for (const [k, v] of Object.entries(rest)) {
if (v !== undefined) data[k] = v
}
if (input.arrivalAt && input.departureAt && input.departureAt < input.arrivalAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Departure must be after arrival',
})
}
const detail = await ctx.prisma.flightDetail.upsert({
where: { attendingMemberId },
create: { attendingMemberId, ...(data as object) },
@@ -198,9 +505,70 @@ export const logisticsRouter = router({
entityId: detail.id,
detailsJson: { status: input.status },
})
// Send travel-confirmed notification to the attendee (best-effort — never throws)
if (input.status === 'CONFIRMED') {
try {
const attendee = await ctx.prisma.attendingMember.findUnique({
where: { id: detail.attendingMemberId },
select: {
userId: true,
hotelStay: {
include: { hotel: { select: { name: true, address: true, link: true } } },
},
confirmation: {
select: {
project: {
select: {
title: true,
},
},
},
},
},
})
if (attendee) {
const projectTitle = attendee.confirmation.project.title
const hotelStay = attendee.hotelStay
await createNotification({
userId: attendee.userId,
type: NotificationTypes.TRAVEL_CONFIRMED,
title: 'Your grand-finale travel is confirmed',
message: 'Your flight details for the grand finale are confirmed.',
linkUrl: '/applicant',
metadata: {
projectTitle,
arrivalAt: detail.arrivalAt?.toISOString() ?? null,
arrivalFlightNumber: detail.arrivalFlightNumber ?? null,
arrivalAirport: detail.arrivalAirport ?? null,
departureAt: detail.departureAt?.toISOString() ?? null,
departureFlightNumber: detail.departureFlightNumber ?? null,
departureAirport: detail.departureAirport ?? null,
hotel: hotelStay?.hotel
? {
name: hotelStay.hotel.name,
address: hotelStay.hotel.address ?? null,
link: hotelStay.hotel.link ?? null,
}
: null,
roomNumber: hotelStay?.roomNumber ?? null,
checkInAt: hotelStay?.checkInAt?.toISOString() ?? null,
checkOutAt: hotelStay?.checkOutAt?.toISOString() ?? null,
},
})
}
} catch (err) {
console.error('[setFlightStatus] Failed to send TRAVEL_CONFIRMED notification:', err)
}
}
return detail
}),
// ─────────────────────────────────────────────────────────────────────────
// Visa applications
// ─────────────────────────────────────────────────────────────────────────
/**
* List all VisaApplication rows for a program, joined with the project +
* attendee + project so the admin Visas tab can render a flat table.
@@ -281,6 +649,18 @@ export const logisticsRouter = router({
.mutation(async ({ ctx, input }) => {
const existing = await ctx.prisma.visaApplication.findUnique({
where: { id: input.id },
include: {
attendingMember: {
select: {
userId: true,
confirmation: {
select: {
project: { select: { title: true } },
},
},
},
},
},
})
if (!existing) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Visa application not found' })
@@ -313,6 +693,43 @@ export const logisticsRouter = router({
next: data,
},
})
// Send visa-status notification to the attendee when a notifiable status changes (best-effort)
const NOTIFIABLE_STATUSES = new Set<VisaStatus>([
'INVITATION_SENT',
'APPOINTMENT_BOOKED',
'GRANTED',
'DENIED',
])
if (
input.status !== undefined &&
input.status !== existing.status &&
NOTIFIABLE_STATUSES.has(input.status)
) {
try {
const projectTitle = existing.attendingMember.confirmation.project.title
const statusMessages: Record<string, string> = {
INVITATION_SENT: 'Your visa invitation letter for the Grand Finale has been sent.',
APPOINTMENT_BOOKED: 'A visa appointment has been booked for you.',
GRANTED: 'Congratulations — your visa for the Grand Finale has been granted!',
DENIED: 'Your visa application outcome is now available. Please contact the MOPC team.',
}
await createNotification({
userId: existing.attendingMember.userId,
type: NotificationTypes.VISA_STATUS_UPDATE,
title: 'Visa update for the grand finale',
message: statusMessages[input.status] ?? 'Your visa status has been updated.',
linkUrl: '/applicant',
metadata: {
projectTitle,
status: input.status,
},
})
} catch (err) {
console.error('[updateVisaApplication] Failed to send VISA_STATUS_UPDATE notification:', err)
}
}
return updated
}),

View File

@@ -1,9 +1,12 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure, protectedProcedure } from '../trpc'
import { router, adminProcedure, protectedProcedure, publicProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
import { buildManifest, buildRecapPayload } from '../services/lunch-recap'
import { sendLunchRecapEmail } from '@/lib/email'
import { selectUnpickedAttendees, selectUnpickedExternals } from '../services/lunch-reminders'
import { sendExternalDishInvite } from '../services/lunch-external-invite'
import { sendLunchRecapEmail, sendLunchReminderEmail } from '@/lib/email'
import { verifyExternalLunchToken } from '@/lib/external-lunch-token'
import { csvCell } from '@/lib/csv'
// ─── Shared zod schemas ──────────────────────────────────────────────────────
@@ -39,7 +42,7 @@ export const lunchRouter = router({
// ─── Dish CRUD ────────────────────────────────────────────────────────────
listDishes: adminProcedure
listDishes: protectedProcedure
.input(z.object({ lunchEventId: z.string() }))
.query(({ ctx, input }) =>
ctx.prisma.dish.findMany({
@@ -178,6 +181,19 @@ export const lunchRouter = router({
entityId: ext.id,
detailsJson: { name: ext.name, projectId: ext.projectId },
})
// Auto-send the dish-selection invite when an email is on file. Never throws
// — a failed send leaves inviteSentAt null so the admin can resend.
if (ext.email) {
try {
const event = await ctx.prisma.lunchEvent.findUnique({
where: { id: input.lunchEventId },
select: { eventAt: true, venue: true, notes: true, changeCutoffHours: true },
})
if (event) await sendExternalDishInvite(ctx.prisma, ext, event)
} catch (e) {
console.error('[lunch.createExternal] dish invite send failed', e)
}
}
return ext
}),
@@ -228,6 +244,159 @@ export const lunchRouter = router({
return { ok: true as const }
}),
/**
* Send (or resend) the dish-selection invite to one external attendee.
* Stamps `inviteSentAt` and audit-logs. Fails if the attendee has no email.
*/
sendExternalInvite: adminProcedure
.input(z.object({ externalId: z.string() }))
.mutation(async ({ ctx, input }) => {
const external = await ctx.prisma.externalAttendee.findUnique({
where: { id: input.externalId },
include: {
lunchEvent: {
select: { eventAt: true, venue: true, notes: true, changeCutoffHours: true },
},
},
})
if (!external) throw new TRPCError({ code: 'NOT_FOUND' })
if (!external.email) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'This attendee has no email address. Add one first.',
})
}
try {
await sendExternalDishInvite(ctx.prisma, external, external.lunchEvent)
} catch (e) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: `Failed to send invite: ${e instanceof Error ? e.message : String(e)}`,
cause: e,
})
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LUNCH_EXTERNAL_INVITE_SENT',
entityType: 'ExternalAttendee',
entityId: external.id,
detailsJson: { email: external.email },
})
return { ok: true as const }
}),
// ─── Public tokenized external dish picker ───────────────────────────────
/**
* Read an external attendee + their event + dishes by a signed, no-login token.
* Token verification throws on bad signature / expiry; the page maps those to
* friendly states.
*/
getExternalByToken: publicProcedure
.input(z.object({ token: z.string() }))
.query(async ({ ctx, input }) => {
const payload = verifyExternalLunchToken(input.token) // throws on bad sig / expired
const external = await ctx.prisma.externalAttendee.findUnique({
where: { id: payload.externalId },
include: {
lunchEvent: {
select: {
id: true,
eventAt: true,
endAt: true,
venue: true,
notes: true,
changeCutoffHours: true,
},
},
},
})
if (!external) throw new TRPCError({ code: 'NOT_FOUND' })
const dishes = await ctx.prisma.dish.findMany({
where: { lunchEventId: external.lunchEventId },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
})
const eventAt = external.lunchEvent.eventAt
const changeDeadline = eventAt
? new Date(eventAt.getTime() - external.lunchEvent.changeCutoffHours * 3_600_000)
: null
return {
external: {
id: external.id,
name: external.name,
dishId: external.dishId,
allergens: external.allergens,
allergenOther: external.allergenOther,
},
event: {
eventAt,
endAt: external.lunchEvent.endAt,
venue: external.lunchEvent.venue,
notes: external.lunchEvent.notes,
},
dishes,
changeDeadline,
}
}),
/**
* Save an external attendee's dish pick via their signed token. Enforces the
* lunch change cutoff (eventAt changeCutoffHours); past it, the attendee must
* contact an admin.
*/
setExternalPick: publicProcedure
.input(
z.object({
token: z.string(),
dishId: z.string().nullable(),
allergens,
allergenOther: z.string().max(500).nullable(),
}),
)
.mutation(async ({ ctx, input }) => {
const payload = verifyExternalLunchToken(input.token)
const external = await ctx.prisma.externalAttendee.findUnique({
where: { id: payload.externalId },
include: {
lunchEvent: { select: { eventAt: true, changeCutoffHours: true } },
},
})
if (!external) throw new TRPCError({ code: 'NOT_FOUND' })
const eventAt = external.lunchEvent.eventAt
if (eventAt) {
const deadline = new Date(
eventAt.getTime() - external.lunchEvent.changeCutoffHours * 3_600_000,
)
if (new Date() > deadline) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Past the lunch change deadline. Please contact an admin.',
})
}
}
// Dish must belong to this attendee's event (defends against cross-event ids).
if (input.dishId) {
const dish = await ctx.prisma.dish.findFirst({
where: { id: input.dishId, lunchEventId: external.lunchEventId },
select: { id: true },
})
if (!dish) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Unknown dish' })
}
const updated = await ctx.prisma.externalAttendee.update({
where: { id: external.id },
data: {
dishId: input.dishId,
allergens: input.allergens,
allergenOther: input.allergenOther,
},
})
return { ok: true as const, dishId: updated.dishId }
}),
// ─── Single-row pick read (used by per-row picker UI) ────────────────────
/**
@@ -346,7 +515,11 @@ export const lunchRouter = router({
await sendLunchRecapEmail(recipients, payload)
} catch (e) {
console.error('[lunch.sendRecap] email send failed', e)
// Continue — we still stamp recapSentAt and audit so admins see what happened.
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: `Recap email failed to send: ${e instanceof Error ? e.message : String(e)}`,
cause: e,
})
}
const updated = await ctx.prisma.lunchEvent.update({
where: { programId: input.programId },
@@ -570,6 +743,58 @@ export const lunchRouter = router({
return pick
}),
/**
* Manually send lunch pick reminders to all unpicked attendees for a given
* LunchEvent. Unlike the cron, this does NOT gate on reminderSentAt and does
* NOT stamp it — manual sends are repeatable. Returns { sent } count.
*/
sendReminders: adminProcedure
.input(z.object({ lunchEventId: z.string() }))
.mutation(async ({ ctx, input }) => {
const event = await ctx.prisma.lunchEvent.findUnique({
where: { id: input.lunchEventId },
})
if (!event) throw new TRPCError({ code: 'NOT_FOUND', message: 'Lunch event not found' })
const ams = await selectUnpickedAttendees(ctx.prisma, event)
const deadline = event.eventAt
? new Date(event.eventAt.getTime() - event.changeCutoffHours * 3_600_000)
: new Date(Date.now() + 48 * 3_600_000)
let sent = 0
for (const am of ams) {
if (!am.user.email) continue
try {
await sendLunchReminderEmail({
to: am.user.email,
memberName: am.user.name ?? am.user.email,
eventAt: event.eventAt ?? new Date(),
venue: event.venue,
changeDeadline: deadline,
pickUrl: `${process.env.NEXTAUTH_URL ?? ''}/applicant`,
})
sent++
} catch (e) {
console.error('[lunch.sendReminders] send failed for', am.user.email, e)
}
}
// External attendees: chase the ones with an email and no dish yet, via
// their own tokenized pick page.
const externals = await selectUnpickedExternals(ctx.prisma, { id: event.id })
for (const ext of externals) {
if (!ext.email) continue
try {
await sendExternalDishInvite(ctx.prisma, ext, event)
sent++
} catch (e) {
console.error('[lunch.sendReminders] external send failed for', ext.email, e)
}
}
return { sent }
}),
/** Patch any subset of LunchEvent config fields. Audit-logged. */
updateEvent: adminProcedure
.input(

View File

@@ -50,6 +50,7 @@ import {
signMentorUploadToken,
verifyMentorUploadToken,
} from '@/lib/mentor-upload-token'
import { getFinalDocumentStatusForProject } from '../services/final-documents'
/**
* True if the project is enrolled in a MENTORING round that is still
@@ -215,6 +216,21 @@ async function assertProjectWorkspaceAccess(
}
export const mentorRouter = router({
/** Grand-final document status for a project, for the mentor workspace panel. */
getProjectFinalDocuments: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!isAdmin) {
const [mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.mentorAssignment.findFirst({ where: { mentorId: ctx.user.id, projectId: input.projectId, droppedAt: null }, select: { id: true } }),
ctx.prisma.teamMember.findFirst({ where: { userId: ctx.user.id, projectId: input.projectId }, select: { id: true } }),
])
if (!mentorAssignment && !teamMembership) throw new TRPCError({ code: 'FORBIDDEN', message: 'No access to this project' })
}
return getFinalDocumentStatusForProject(ctx.prisma, input.projectId)
}),
/**
* Get AI-suggested mentor matches for a project
*/
@@ -315,7 +331,10 @@ export const mentorRouter = router({
const mentors = await ctx.prisma.user.findMany({
where: {
roles: { has: 'MENTOR' },
// MENTOR as primary role OR in the secondary roles[] array. Some legacy
// users have role=MENTOR with an empty roles[], so checking roles only
// would wrongly exclude them (mirrors userHasRole's fallback).
OR: [{ role: 'MENTOR' }, { roles: { has: 'MENTOR' } }],
status: { not: 'SUSPENDED' },
},
select: {
@@ -728,9 +747,11 @@ export const mentorRouter = router({
.mutation(async ({ ctx, input }) => {
const mentors = await ctx.prisma.user.findMany({
where: { id: { in: input.mentorIds } },
select: { id: true, name: true, email: true, roles: true },
select: { id: true, name: true, email: true, role: true, roles: true },
})
const validMentors = mentors.filter((m) => m.roles.includes('MENTOR'))
// Accept MENTOR as primary role OR in roles[] (legacy users may have
// role=MENTOR with an empty roles[] array).
const validMentors = mentors.filter((m) => m.roles.includes('MENTOR') || m.role === 'MENTOR')
if (validMentors.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
@@ -1574,7 +1595,9 @@ export const mentorRouter = router({
.query(async ({ ctx, input }) => {
const mentors = await ctx.prisma.user.findMany({
where: {
roles: { has: 'MENTOR' },
// MENTOR as primary role OR in the secondary roles[] array (see
// getCandidates: legacy users may have role=MENTOR with empty roles[]).
OR: [{ role: 'MENTOR' }, { roles: { has: 'MENTOR' } }],
status: { not: 'SUSPENDED' },
},
select: {

View File

@@ -15,7 +15,128 @@ import {
NotificationIcons,
NotificationPriorities,
} from '../services/in-app-notification'
import { sendStyledNotificationEmail, NOTIFICATION_EMAIL_TEMPLATES } from '@/lib/email'
import { sendStyledNotificationEmail, renderNotificationEmail, NOTIFICATION_EMAIL_TEMPLATES } from '@/lib/email'
/**
* Sample data used for test emails and previews, keyed by notification type.
*/
const NOTIFICATION_SAMPLE_DATA: Record<string, Record<string, unknown>> = {
// Team notifications
ADVANCED_SEMIFINAL: {
projectName: 'Ocean Cleanup Initiative',
programName: 'Monaco Ocean Protection Challenge 2026',
nextSteps: 'Prepare your presentation for the semi-final round.',
},
ADVANCED_FINAL: {
projectName: 'Ocean Cleanup Initiative',
programName: 'Monaco Ocean Protection Challenge 2026',
nextSteps: 'Get ready for the final presentation in Monaco.',
},
MENTOR_ASSIGNED: {
projectName: 'Ocean Cleanup Initiative',
mentorName: 'Dr. Marine Expert',
mentorBio: 'Expert in marine conservation with 20 years of experience.',
},
NOT_SELECTED: {
projectName: 'Ocean Cleanup Initiative',
roundName: 'Semi-Final Round',
},
WINNER_ANNOUNCEMENT: {
projectName: 'Ocean Cleanup Initiative',
awardName: 'Grand Prize',
prizeDetails: '€50,000 and mentorship program',
},
// Jury notifications
ASSIGNED_TO_PROJECT: {
projectName: 'Ocean Cleanup Initiative',
roundName: 'Semi-Final Round',
deadline: 'Friday, March 15, 2026',
},
BATCH_ASSIGNED: {
projectCount: 5,
roundName: 'Semi-Final Round',
deadline: 'Friday, March 15, 2026',
},
ROUND_NOW_OPEN: {
roundName: 'Semi-Final Round',
projectCount: 12,
deadline: 'Friday, March 15, 2026',
},
REMINDER_24H: {
pendingCount: 3,
roundName: 'Semi-Final Round',
deadline: 'Tomorrow at 5:00 PM',
},
REMINDER_1H: {
pendingCount: 2,
roundName: 'Semi-Final Round',
deadline: 'Today at 5:00 PM',
},
AWARD_VOTING_OPEN: {
awardName: 'Innovation Award',
finalistCount: 6,
deadline: 'Friday, March 15, 2026',
},
// Mentor notifications
MENTEE_ASSIGNED: {
projectName: 'Ocean Cleanup Initiative',
teamLeadName: 'John Smith',
teamLeadEmail: 'john@example.com',
},
MENTEE_ADVANCED: {
projectName: 'Ocean Cleanup Initiative',
roundName: 'Semi-Final Round',
nextRoundName: 'Final Round',
},
MENTEE_WON: {
projectName: 'Ocean Cleanup Initiative',
awardName: 'Innovation Award',
},
// Admin notifications
NEW_APPLICATION: {
projectName: 'New Ocean Project',
applicantName: 'Jane Doe',
applicantEmail: 'jane@example.com',
programName: 'Monaco Ocean Protection Challenge 2026',
},
FILTERING_COMPLETE: {
roundName: 'Initial Review',
passedCount: 45,
flaggedCount: 12,
filteredCount: 8,
},
FILTERING_FAILED: {
roundName: 'Initial Review',
error: 'Connection timeout',
},
// Logistics notifications
FINALIST_CONFIRMED: { projectTitle: 'Ocean Cleanup Initiative', category: 'STARTUP' },
FINALIST_DECLINED: { projectTitle: 'Ocean Cleanup Initiative', category: 'STARTUP' },
FINALIST_EXPIRED: { projectTitle: 'Ocean Cleanup Initiative', category: 'STARTUP' },
FINALIST_WAITLIST_PROMOTED: { projectTitle: 'Reef Guardians', category: 'STARTUP' },
FINALIST_REMINDER: { projectTitle: 'Ocean Cleanup Initiative', deadline: new Date(Date.now() + 86_400_000).toISOString() },
GRAND_FINAL_DOCS_REMINDER: {
projectTitle: 'Ocean Cleanup Initiative',
deadline: new Date(Date.now() + 5 * 86_400_000).toISOString(),
missing: ['Pitch deck', 'Final video', 'Team photo'],
},
FINALIST_WITHDRAWN: { projectTitle: 'Ocean Cleanup Initiative', reason: 'Schedule conflict' },
TRAVEL_CONFIRMED: {
projectTitle: 'Ocean Cleanup Initiative',
arrivalAt: new Date(Date.now() + 5 * 86_400_000).toISOString(),
arrivalFlightNumber: 'AF1234',
arrivalAirport: 'NCE',
departureAt: new Date(Date.now() + 7 * 86_400_000).toISOString(),
departureFlightNumber: 'AF1235',
departureAirport: 'NCE',
hotel: { name: 'Hotel de Paris', address: 'Place du Casino, Monaco', link: 'https://example.com' },
},
VISA_STATUS_UPDATE: { projectTitle: 'Ocean Cleanup Initiative', status: 'GRANTED' },
}
export const notificationRouter = router({
/**
@@ -253,102 +374,7 @@ export const notificationRouter = router({
where: { notificationType },
})
// Sample data for test emails based on category
const sampleData: Record<string, Record<string, unknown>> = {
// Team notifications
ADVANCED_SEMIFINAL: {
projectName: 'Ocean Cleanup Initiative',
programName: 'Monaco Ocean Protection Challenge 2026',
nextSteps: 'Prepare your presentation for the semi-final round.',
},
ADVANCED_FINAL: {
projectName: 'Ocean Cleanup Initiative',
programName: 'Monaco Ocean Protection Challenge 2026',
nextSteps: 'Get ready for the final presentation in Monaco.',
},
MENTOR_ASSIGNED: {
projectName: 'Ocean Cleanup Initiative',
mentorName: 'Dr. Marine Expert',
mentorBio: 'Expert in marine conservation with 20 years of experience.',
},
NOT_SELECTED: {
projectName: 'Ocean Cleanup Initiative',
roundName: 'Semi-Final Round',
},
WINNER_ANNOUNCEMENT: {
projectName: 'Ocean Cleanup Initiative',
awardName: 'Grand Prize',
prizeDetails: '€50,000 and mentorship program',
},
// Jury notifications
ASSIGNED_TO_PROJECT: {
projectName: 'Ocean Cleanup Initiative',
roundName: 'Semi-Final Round',
deadline: 'Friday, March 15, 2026',
},
BATCH_ASSIGNED: {
projectCount: 5,
roundName: 'Semi-Final Round',
deadline: 'Friday, March 15, 2026',
},
ROUND_NOW_OPEN: {
roundName: 'Semi-Final Round',
projectCount: 12,
deadline: 'Friday, March 15, 2026',
},
REMINDER_24H: {
pendingCount: 3,
roundName: 'Semi-Final Round',
deadline: 'Tomorrow at 5:00 PM',
},
REMINDER_1H: {
pendingCount: 2,
roundName: 'Semi-Final Round',
deadline: 'Today at 5:00 PM',
},
AWARD_VOTING_OPEN: {
awardName: 'Innovation Award',
finalistCount: 6,
deadline: 'Friday, March 15, 2026',
},
// Mentor notifications
MENTEE_ASSIGNED: {
projectName: 'Ocean Cleanup Initiative',
teamLeadName: 'John Smith',
teamLeadEmail: 'john@example.com',
},
MENTEE_ADVANCED: {
projectName: 'Ocean Cleanup Initiative',
roundName: 'Semi-Final Round',
nextRoundName: 'Final Round',
},
MENTEE_WON: {
projectName: 'Ocean Cleanup Initiative',
awardName: 'Innovation Award',
},
// Admin notifications
NEW_APPLICATION: {
projectName: 'New Ocean Project',
applicantName: 'Jane Doe',
applicantEmail: 'jane@example.com',
programName: 'Monaco Ocean Protection Challenge 2026',
},
FILTERING_COMPLETE: {
roundName: 'Initial Review',
passedCount: 45,
flaggedCount: 12,
filteredCount: 8,
},
FILTERING_FAILED: {
roundName: 'Initial Review',
error: 'Connection timeout',
},
}
const metadata = sampleData[notificationType] || {}
const metadata = NOTIFICATION_SAMPLE_DATA[notificationType] || {}
const label = setting?.label || notificationType
try {
@@ -378,4 +404,35 @@ export const notificationRouter = router({
}
}
}),
/**
* Preview a notification email template without sending it.
* Returns the rendered HTML and subject so admins can see the email before sending.
*/
previewEmailTemplate: adminProcedure
.input(z.object({ notificationType: z.string() }))
.query(async ({ ctx, input }) => {
const setting = await ctx.prisma.notificationEmailSetting.findUnique({
where: { notificationType: input.notificationType },
})
const label = setting?.label || input.notificationType
const metadata = NOTIFICATION_SAMPLE_DATA[input.notificationType] || {}
const rendered = renderNotificationEmail(
ctx.user.name || 'Admin',
input.notificationType,
{
title: label,
message: `Preview of the "${label}" email.`,
linkUrl: `${process.env.NEXTAUTH_URL || ''}/applicant`,
linkLabel: 'Open',
metadata,
},
setting?.emailSubject || undefined,
)
return {
subject: rendered.subject,
html: rendered.html,
hasStyledTemplate: input.notificationType in NOTIFICATION_EMAIL_TEMPLATES,
}
}),
})

View File

@@ -714,6 +714,7 @@ export const projectRouter = router({
email: member.email.toLowerCase(),
name: member.name,
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'NONE',
phoneNumber: member.phone || null,
},

View File

@@ -159,11 +159,18 @@ export const roundRouter = router({
const existing = await ctx.prisma.round.findUniqueOrThrow({ where: { id } })
// If configJson provided, validate it against the round type
// If configJson provided, validate it against the round type, then MERGE
// over the existing config: the validator strips keys it doesn't know,
// but configJson also carries operational state written outside this
// form (ceremony projectOrder, projectTimingOverrides, finals-docs
// upload toggle). Replacing wholesale would wipe a running ceremony.
let validatedConfig: Prisma.InputJsonValue | undefined
if (configJson) {
const parsed = validateRoundConfig(existing.roundType, configJson)
validatedConfig = parsed as unknown as Prisma.InputJsonValue
validatedConfig = {
...((existing.configJson as Record<string, unknown>) ?? {}),
...(parsed as Record<string, unknown>),
} as unknown as Prisma.InputJsonValue
}
const round = await ctx.prisma.round.update({

View File

@@ -697,6 +697,7 @@ export const specialAwardRouter = router({
email: invitee.email,
name: invitee.name || null,
role: 'JURY_MEMBER',
roles: ['JURY_MEMBER'],
status: 'INVITED',
inviteToken,
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),

View File

@@ -232,19 +232,26 @@ export const userRouter = router({
const skip = (page - 1) * perPage
const where: Record<string, unknown> = {}
const and: Record<string, unknown>[] = []
// Match the role as EITHER the primary role (User.role) or a secondary
// role (User.roles[]) so the type tabs include multi-role users, mirroring
// the multi-role checks used elsewhere (userHasRole / hasRole middleware).
if (roles && roles.length > 0) {
where.role = { in: roles }
and.push({ OR: [{ role: { in: roles } }, { roles: { hasSome: roles } }] })
} else if (role) {
where.role = role
and.push({ OR: [{ role }, { roles: { has: role } }] })
}
if (status) where.status = status
if (search) {
where.OR = [
{ email: { contains: search, mode: 'insensitive' } },
{ name: { contains: search, mode: 'insensitive' } },
]
and.push({
OR: [
{ email: { contains: search, mode: 'insensitive' } },
{ name: { contains: search, mode: 'insensitive' } },
],
})
}
if (and.length > 0) where.AND = and
const dir = sortDir ?? 'asc'
const orderBy: Record<string, string> = sortBy
@@ -373,19 +380,24 @@ export const userRouter = router({
const where: Record<string, unknown> = {
status: { in: ['NONE', 'INVITED'] },
}
const and: Record<string, unknown>[] = []
// Match primary OR secondary role (see user.list for rationale).
if (input.roles && input.roles.length > 0) {
where.role = { in: input.roles }
and.push({ OR: [{ role: { in: input.roles } }, { roles: { hasSome: input.roles } }] })
} else if (input.role) {
where.role = input.role
and.push({ OR: [{ role: input.role }, { roles: { has: input.role } }] })
}
if (input.search) {
where.OR = [
{ email: { contains: input.search, mode: 'insensitive' } },
{ name: { contains: input.search, mode: 'insensitive' } },
]
and.push({
OR: [
{ email: { contains: input.search, mode: 'insensitive' } },
{ name: { contains: input.search, mode: 'insensitive' } },
],
})
}
if (and.length > 0) where.AND = and
const users = await ctx.prisma.user.findMany({
where,
@@ -498,6 +510,7 @@ export const userRouter = router({
const user = await ctx.prisma.user.create({
data: {
...input,
roles: [input.role],
status: 'INVITED',
inviteToken,
inviteTokenExpiresAt: new Date(Date.now() + expiryHours * 60 * 60 * 1000),
@@ -799,6 +812,7 @@ export const userRouter = router({
email: u.email.toLowerCase(),
name: u.name,
role: u.role,
roles: [u.role],
expertiseTags: u.expertiseTags,
status: input.sendInvitation ? 'INVITED' : 'NONE',
})),

View File

@@ -615,7 +615,7 @@ export async function getSessionWithVotes(
sessionId: string,
prisma: PrismaClient,
) {
return prisma.deliberationSession.findUnique({
const session = await prisma.deliberationSession.findUnique({
where: { id: sessionId },
include: {
votes: {
@@ -648,6 +648,23 @@ export async function getSessionWithVotes(
round: { select: { id: true, name: true, roundType: true } },
},
})
if (!session) return null
// Rankable projects: the round's projects in this session's category.
// (results are empty until finalize — the jury ranking form needs this list.)
const roundStates = await prisma.projectRoundState.findMany({
where: {
roundId: session.roundId,
project: { competitionCategory: session.category },
},
include: {
project: {
select: { id: true, title: true, teamName: true, competitionCategory: true },
},
},
})
return { ...session, projects: roundStates.map((rs) => rs.project) }
}
// ─── Internal Helpers ───────────────────────────────────────────────────────

View File

@@ -0,0 +1,391 @@
import type { PrismaClient, RoundStatus } 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
hasRequired: boolean // any slot is marked required
allUploaded: boolean // every listed slot has a file (false when no slots exist)
}
// A LIVE_FINAL round is "open for documents" during the lead-up — while it is
// DRAFT (not yet opened for the live event) or ACTIVE — but not once CLOSED/
// finalized. This decouples document upload + judge review from the ceremony
// actually starting, so the Grand Final round can stay DRAFT until event time.
const OPEN_FINALE_STATUS: RoundStatus[] = ['ROUND_DRAFT', 'ROUND_ACTIVE']
/** Resolve the program's LIVE_FINAL round while it is open for documents (DRAFT or ACTIVE, not closed/finalized), or null. */
export async function getOpenFinaleRound(prisma: PrismaClient, programId: string) {
return prisma.round.findFirst({
where: { competition: { programId }, roundType: 'LIVE_FINAL', status: { in: OPEN_FINALE_STATUS }, finalizedAt: null },
orderBy: { sortOrder: 'desc' },
select: { id: true, name: true, windowCloseAt: true, configJson: true },
})
}
/**
* Whether finalist teams are allowed to upload *revised* documents for the
* grand final. This is an admin toggle on the LIVE_FINAL round's configJson.
* When false (default), judges still see the teams' existing prior-round
* submissions, but teams are not prompted/able to upload anything new.
*/
export function finalistUploadsEnabled(configJson: unknown): boolean {
return !!(configJson as { allowFinalistRevisedUploads?: boolean } | null)?.allowFinalistRevisedUploads
}
/**
* Which prior-round FileRequirement ids are visible to finale judges.
* null = no curation (show all prior files). Empty array = hide all prior
* files (Grand Final round uploads are always shown regardless).
*/
export function reviewVisibleRequirementIds(configJson: unknown): string[] | null {
const v = (configJson as { reviewVisibleRequirementIds?: unknown } | null)?.reviewVisibleRequirementIds
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : null
}
/**
* 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 getOpenFinaleRound(prisma, project.programId)
// Banner / upload status only applies when the admin has enabled revised uploads.
if (!round || !finalistUploadsEnabled(round.configJson)) 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 hasRequired = required.length > 0
const allUploaded = reqStatuses.length > 0 && reqStatuses.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,
hasRequired,
allUploaded,
}
}
/** 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 getOpenFinaleRound(prisma, opts.programId)
if (!round || !finalistUploadsEnabled(round.configJson)) 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: { in: OPEN_FINALE_STATUS }, finalizedAt: null },
select: { id: true, windowCloseAt: true, configJson: true, competition: { select: { programId: true } } },
})
let remindersSent = 0
for (const round of rounds) {
if (!round.windowCloseAt) continue
// Only chase teams to upload when the admin has enabled revised uploads.
if (!finalistUploadsEnabled(round.configJson)) 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 ReviewFile = {
id: string
fileName: string
mimeType: string
url: string
docLabel: string // requirement name if known, else a humanized file type
roundLabel: string // which round this was submitted in
roundSort: number
isFinaleUpload: boolean // uploaded directly to the LIVE_FINAL round (a revised "final")
createdAt: Date
}
export type ReviewTeam = { projectId: string; teamName: string; category: string | null; files: ReviewFile[] }
export type ReviewPayload = { round: { id: string; name: string; deadline: Date | null }; totalCount: number; teams: ReviewTeam[] }
function humanizeFileType(t: string | null | undefined): string {
switch (t) {
case 'EXEC_SUMMARY': return 'Executive Summary'
case 'BUSINESS_PLAN': return 'Business Plan'
case 'PRESENTATION': return 'Presentation'
case 'VIDEO_PITCH': return 'Video Pitch'
case 'VIDEO': return 'Video'
case 'SUPPORTING_DOC': return 'Supporting Document'
default: return 'Document'
}
}
/**
* Read-only review payload for finale judges: every finalist team enrolled in
* the program's LIVE_FINAL round, with ALL of their submitted files across every
* round (pitch deck, executive summary, business plan, videos, plus any revised
* finals uploads). Each 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. This is NOT gated on the upload toggle:
* judges can always review the teams' existing submissions.
*/
export async function listFinalistDocumentsForReview(prisma: PrismaClient, programId: string): Promise<ReviewPayload> {
const round = await getOpenFinaleRound(prisma, programId)
if (!round) return { round: { id: '', name: '', deadline: null }, totalCount: 0, teams: [] }
// Admin curation: which prior-round documents judges may see (null = all).
const visibleIds = reviewVisibleRequirementIds(round.configJson)
const states = await prisma.projectRoundState.findMany({
where: { roundId: round.id },
select: { project: { select: { id: true, title: true, teamName: true, competitionCategory: true } } },
})
const projectIds = states.map((s) => s.project.id)
// Every file these teams have submitted, in any round.
const allFiles = await prisma.projectFile.findMany({
where: { projectId: { in: projectIds } },
orderBy: { createdAt: 'desc' },
select: {
id: true, projectId: true, fileName: true, mimeType: true, fileType: true, requirementId: true,
bucket: true, objectKey: true, createdAt: true, roundId: true,
requirement: { select: { name: true, round: { select: { name: true, sortOrder: true } } } },
},
})
// Resolve round names for files attached directly to a round (no requirement).
const directRoundIds = [...new Set(allFiles.filter((f) => f.roundId && !f.requirement).map((f) => f.roundId!))]
const directRounds = directRoundIds.length
? await prisma.round.findMany({ where: { id: { in: directRoundIds } }, select: { id: true, name: true, sortOrder: true } })
: []
const roundById = new Map(directRounds.map((r) => [r.id, r]))
const filesByProject = new Map<string, ReviewFile[]>()
for (const f of allFiles) {
const isFinaleUpload = f.roundId === round.id
// Curated mode: prior-round files must match a selected requirement; finale uploads always pass.
if (!isFinaleUpload && visibleIds !== null && (!f.requirementId || !visibleIds.includes(f.requirementId))) continue
const r = f.requirement?.round ?? (f.roundId ? roundById.get(f.roundId) : null)
const rf: ReviewFile = {
id: f.id,
fileName: f.fileName,
mimeType: f.mimeType,
url: await getPresignedUrl(f.bucket, f.objectKey, 'GET', 3600),
docLabel: f.requirement?.name?.trim() || humanizeFileType(f.fileType),
roundLabel: r?.name ?? '—',
roundSort: r?.sortOrder ?? -1,
isFinaleUpload,
createdAt: f.createdAt,
}
const list = filesByProject.get(f.projectId)
if (list) list.push(rf)
else filesByProject.set(f.projectId, [rf])
}
const teams: ReviewTeam[] = states.map(({ project }) => ({
projectId: project.id,
teamName: project.teamName || project.title,
category: project.competitionCategory,
files: (filesByProject.get(project.id) ?? []).sort(
(a, b) => b.roundSort - a.roundSort || a.docLabel.localeCompare(b.docLabel),
),
}))
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, teams }
}
export type ReviewDocSlot = {
requirementId: string
name: string
roundName: string
roundSort: number
fileCount: number
}
/**
* Distinct prior-round document slots (FileRequirements) that the finalist
* teams have files for — the options offered in the admin "documents shown to
* judges" picker. Excludes the finale round's own slots (those uploads are
* always visible to judges) and files without a requirement.
*/
export async function listReviewVisibilityOptions(prisma: PrismaClient, programId: string): Promise<ReviewDocSlot[]> {
const round = await getOpenFinaleRound(prisma, programId)
if (!round) return []
const states = await prisma.projectRoundState.findMany({ where: { roundId: round.id }, select: { projectId: true } })
const files = await prisma.projectFile.findMany({
where: { projectId: { in: states.map((s) => s.projectId) }, requirement: { roundId: { not: round.id } } },
select: {
requirementId: true,
requirement: { select: { name: true, round: { select: { name: true, sortOrder: true } } } },
},
})
const slots = new Map<string, ReviewDocSlot>()
for (const f of files) {
if (!f.requirementId || !f.requirement) continue
const existing = slots.get(f.requirementId)
if (existing) existing.fileCount++
else slots.set(f.requirementId, {
requirementId: f.requirementId,
name: f.requirement.name.trim(),
roundName: f.requirement.round.name,
roundSort: f.requirement.round.sortOrder,
fileCount: 1,
})
}
return [...slots.values()].sort((a, b) => a.roundSort - b.roundSort || a.name.localeCompare(b.name))
}
/** True if user is admin or a member of the program's open LIVE_FINAL jury group (DRAFT or ACTIVE). */
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: { in: OPEN_FINALE_STATUS }, finalizedAt: null },
orderBy: { sortOrder: 'desc' },
select: { juryGroupId: true },
})
if (!round?.juryGroupId) return false
const member = await prisma.juryGroupMember.findFirst({ where: { juryGroupId: round.juryGroupId, userId }, select: { id: true } })
return !!member
}

View File

@@ -2,6 +2,7 @@ import type { CompetitionCategory, PrismaClient } from '@prisma/client'
import { signFinalistToken } from '@/lib/finalist-token'
import { sendFinalistConfirmationEmail } from '@/lib/email'
import { logAudit } from '@/server/utils/audit'
import { notifyAdmins, createNotification, NotificationTypes } from './in-app-notification'
type AnyPrisma = Pick<PrismaClient, 'finalistConfirmation' | 'waitlistEntry' | 'project'>
@@ -104,6 +105,27 @@ export async function promoteNextWaitlistEntry(
}
}
// Admin alert — best-effort, never throws
try {
const projectTitle = project?.title ?? entry.projectId
await notifyAdmins({
type: NotificationTypes.FINALIST_WAITLIST_PROMOTED,
title: 'Waitlist entry promoted',
message: `"${projectTitle}" (${args.category}) was promoted from the waitlist.`,
linkUrl: '/admin/logistics',
metadata: {
projectId: entry.projectId,
projectTitle,
category: args.category,
},
})
} catch (err) {
console.error(
`[promoteNextWaitlistEntry] failed to send admin notification for project ${entry.projectId}:`,
err,
)
}
return { promoted: true, entryId: entry.id, confirmationId }
}
@@ -131,6 +153,31 @@ export async function expirePendingPastDeadline(
entityId: c.id,
detailsJson: { projectId: c.projectId, category: c.category },
})
// Admin alert — best-effort, never throws
try {
// Resolve project title for a meaningful notification message
const proj = await prisma.project.findUnique({
where: { id: c.projectId },
select: { title: true },
})
const projectTitle = proj?.title ?? c.projectId
await notifyAdmins({
type: NotificationTypes.FINALIST_EXPIRED,
title: 'Finalist confirmation expired',
message: `"${projectTitle}" (${c.category}) did not confirm in time — confirmation expired.`,
linkUrl: '/admin/logistics',
metadata: {
projectId: c.projectId,
projectTitle,
category: c.category,
},
})
} catch (err) {
console.error(
`[expirePendingPastDeadline] failed to send admin notification for project ${c.projectId}:`,
err,
)
}
// Resolve windowHours for this program's grand-finale round
const round = await prisma.round.findFirst({
where: {
@@ -151,3 +198,116 @@ export async function expirePendingPastDeadline(
}
return { expired: expired.length, promoted }
}
/**
* Cron entrypoint: send pre-deadline confirmation reminders.
*
* For each PENDING confirmation that has not yet received a reminder
* (reminderSentAt IS NULL) and whose deadline is still in the future but
* within the program's configured `reminderHoursBeforeDeadline` window
* (default 12 h), send a FINALIST_REMINDER in-app notification (+ email via
* the notification pipeline) to the project's LEAD team member, then stamp
* `reminderSentAt` so the row is never processed again.
*
* Best-effort per row — a failure on one row never aborts the rest.
*/
export async function sendDueConfirmationReminders(
prisma: PrismaClient,
): Promise<{ remindersSent: number }> {
const now = new Date()
// Load all candidates: PENDING, no reminder sent yet, deadline still future.
const candidates = await prisma.finalistConfirmation.findMany({
where: {
status: 'PENDING',
reminderSentAt: null,
deadline: { gt: now },
},
include: {
project: {
select: {
id: true,
title: true,
programId: true,
teamMembers: {
where: { role: 'LEAD' },
take: 1,
select: {
userId: true,
user: { select: { name: true } },
},
},
},
},
},
})
if (candidates.length === 0) return { remindersSent: 0 }
// Cache reminderHoursBeforeDeadline per programId to avoid repeat queries.
const reminderHoursCache = new Map<string, number>()
async function getReminderHours(programId: string): Promise<number> {
if (reminderHoursCache.has(programId)) {
return reminderHoursCache.get(programId)!
}
const round = await prisma.round.findFirst({
where: {
competition: { programId },
roundType: 'LIVE_FINAL',
},
orderBy: { sortOrder: 'desc' },
select: { configJson: true },
})
const cfg = (round?.configJson ?? {}) as { reminderHoursBeforeDeadline?: number }
const hours = cfg.reminderHoursBeforeDeadline ?? 12
reminderHoursCache.set(programId, hours)
return hours
}
const baseUrl = (process.env.NEXTAUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '')
let remindersSent = 0
for (const row of candidates) {
try {
const reminderHours = await getReminderHours(row.project.programId)
const windowMs = reminderHours * 3_600_000
const isDue = row.deadline.getTime() <= now.getTime() + windowMs
if (!isDue) continue
const lead = row.project.teamMembers[0]
if (!lead) continue
const confirmUrl = `${baseUrl}/finalist/confirm/${row.token}`
const title = row.project.title
await createNotification({
userId: lead.userId,
type: NotificationTypes.FINALIST_REMINDER,
title: 'Reminder: confirm your grand-finale attendance',
message: `Please confirm attendance for "${title}" before the deadline.`,
linkUrl: confirmUrl,
metadata: {
projectTitle: title,
projectId: row.project.id,
deadline: row.deadline.toISOString(),
},
})
await prisma.finalistConfirmation.update({
where: { id: row.id },
data: { reminderSentAt: new Date() },
})
remindersSent++
} catch (err) {
console.error(
`[sendDueConfirmationReminders] failed for confirmation ${row.id} (project ${row.projectId}):`,
err,
)
}
}
return { remindersSent }
}

View File

@@ -0,0 +1,114 @@
import type { CompetitionCategory, Prisma, PrismaClient } from '@prisma/client'
import { signFinalistToken } from '@/lib/finalist-token'
import { ensureLunchPickForAttendingMember } from './lunch-pick-sync'
type TxClient = PrismaClient | Prisma.TransactionClient
/**
* Re-invite-safe variant of createPendingConfirmation. If a confirmation row
* already exists for the project (projectId is @unique), reset any
* non-CONFIRMED row back to a fresh PENDING with a new token/deadline and
* clear stale attendee rows; report CONFIRMED rows as a no-op so callers can
* skip them. Returns the row id + token + deadline for the email step.
*/
export async function resetOrCreatePendingConfirmation(
prisma: TxClient,
args: { projectId: string; category: CompetitionCategory; windowHours: number },
): Promise<{ id: string; token: string; deadline: Date; alreadyConfirmed: boolean }> {
const deadline = new Date(Date.now() + args.windowHours * 3_600_000)
const existing = await prisma.finalistConfirmation.findUnique({
where: { projectId: args.projectId },
select: { id: true, status: true },
})
if (existing?.status === 'CONFIRMED') {
return { id: existing.id, token: '', deadline, alreadyConfirmed: true }
}
if (existing) {
const token = signFinalistToken({
confirmationId: existing.id,
exp: Math.floor(deadline.getTime() / 1000),
})
// Clear any attendee rows from a prior cycle (cascade-deletes flight/visa/lunch).
await prisma.attendingMember.deleteMany({ where: { confirmationId: existing.id } })
await prisma.finalistConfirmation.update({
where: { id: existing.id },
data: {
category: args.category,
status: 'PENDING',
deadline,
token,
confirmedAt: null,
declinedAt: null,
declineReason: null,
expiredAt: null,
reminderSentAt: null,
},
})
return { id: existing.id, token, deadline, alreadyConfirmed: false }
}
const id = `cmfc_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`
const token = signFinalistToken({
confirmationId: id,
exp: Math.floor(deadline.getTime() / 1000),
})
await prisma.finalistConfirmation.create({
data: {
id,
projectId: args.projectId,
category: args.category,
status: 'PENDING',
deadline,
token,
},
})
return { id, token, deadline, alreadyConfirmed: false }
}
/**
* Shared confirm transaction: atomically writes CONFIRMED status + attendee
* rows + visa applications + lunch picks.
* Called from both `adminConfirm` and `enrollFinalists` (ADMIN_CONFIRM mode).
*
* Must be called inside a Prisma $transaction block — `tx` is the transaction
* client, NOT the top-level prisma client.
*/
export async function confirmAttendanceInTx(
tx: Prisma.TransactionClient,
args: {
confirmationId: string
attendingUserIds: string[]
visaFlags: Record<string, boolean>
},
): Promise<void> {
await tx.finalistConfirmation.update({
where: { id: args.confirmationId },
data: { status: 'CONFIRMED', confirmedAt: new Date() },
})
await tx.attendingMember.createMany({
data: args.attendingUserIds.map((userId) => ({
confirmationId: args.confirmationId,
userId,
needsVisa: args.visaFlags[userId] ?? false,
})),
})
const visaUsers = args.attendingUserIds.filter((uid) => args.visaFlags[uid] === true)
if (visaUsers.length > 0) {
const created = await tx.attendingMember.findMany({
where: { confirmationId: args.confirmationId, userId: { in: visaUsers } },
select: { id: true },
})
await tx.visaApplication.createMany({
data: created.map((m) => ({ attendingMemberId: m.id, status: 'REQUESTED' })),
})
}
const allMembers = await tx.attendingMember.findMany({
where: { confirmationId: args.confirmationId, userId: { in: args.attendingUserIds } },
select: { id: true },
})
for (const m of allMembers) {
await ensureLunchPickForAttendingMember(tx, m.id)
}
}

View File

@@ -95,6 +95,18 @@ export const NotificationTypes = {
FINALISTS_ANNOUNCED: 'FINALISTS_ANNOUNCED',
WINNERS_ANNOUNCED: 'WINNERS_ANNOUNCED',
REPORT_AVAILABLE: 'REPORT_AVAILABLE',
// Logistics
FINALIST_CONFIRMED: 'FINALIST_CONFIRMED',
FINALIST_DECLINED: 'FINALIST_DECLINED',
FINALIST_EXPIRED: 'FINALIST_EXPIRED',
FINALIST_WAITLIST_PROMOTED: 'FINALIST_WAITLIST_PROMOTED',
FINALIST_REMINDER: 'FINALIST_REMINDER',
FINALIST_WITHDRAWN: 'FINALIST_WITHDRAWN',
TRAVEL_CONFIRMED: 'TRAVEL_CONFIRMED',
VISA_STATUS_UPDATE: 'VISA_STATUS_UPDATE',
GRAND_FINAL_DOCS_REMINDER: 'GRAND_FINAL_DOCS_REMINDER',
GRAND_FINAL_DOCS_SUBMITTED: 'GRAND_FINAL_DOCS_SUBMITTED',
} as const
export type NotificationType = (typeof NotificationTypes)[keyof typeof NotificationTypes]
@@ -129,6 +141,14 @@ export const NotificationIcons: Record<string, string> = {
[NotificationTypes.AWARD_RESULTS]: 'Trophy',
[NotificationTypes.AI_RANKING_COMPLETE]: 'BarChart3',
[NotificationTypes.AI_RANKING_FAILED]: 'AlertTriangle',
[NotificationTypes.FINALIST_CONFIRMED]: 'CheckCircle',
[NotificationTypes.FINALIST_DECLINED]: 'XCircle',
[NotificationTypes.FINALIST_EXPIRED]: 'AlertTriangle',
[NotificationTypes.FINALIST_WAITLIST_PROMOTED]: 'TrendingUp',
[NotificationTypes.FINALIST_REMINDER]: 'Clock',
[NotificationTypes.FINALIST_WITHDRAWN]: 'UserMinus',
[NotificationTypes.TRAVEL_CONFIRMED]: 'Plane',
[NotificationTypes.VISA_STATUS_UPDATE]: 'FileText',
}
// Priority by notification type
@@ -155,6 +175,14 @@ export const NotificationPriorities: Record<string, NotificationPriority> = {
[NotificationTypes.AWARD_VOTING_OPEN]: 'high',
[NotificationTypes.AI_RANKING_COMPLETE]: 'normal',
[NotificationTypes.AI_RANKING_FAILED]: 'high',
[NotificationTypes.FINALIST_EXPIRED]: 'urgent',
[NotificationTypes.FINALIST_REMINDER]: 'high',
[NotificationTypes.FINALIST_WITHDRAWN]: 'high',
[NotificationTypes.FINALIST_CONFIRMED]: 'normal',
[NotificationTypes.FINALIST_DECLINED]: 'high',
[NotificationTypes.FINALIST_WAITLIST_PROMOTED]: 'high',
[NotificationTypes.TRAVEL_CONFIRMED]: 'high',
[NotificationTypes.VISA_STATUS_UPDATE]: 'high',
}
interface CreateNotificationParams {

View File

@@ -0,0 +1,61 @@
import type { Prisma, PrismaClient } from '@prisma/client'
import { signExternalLunchToken } from '@/lib/external-lunch-token'
import { getBaseUrl, sendExternalDishInviteEmail } from '@/lib/email'
type PrismaLike = PrismaClient | Prisma.TransactionClient
export type InvitableExternal = {
id: string
name: string
email: string | null
}
export type InviteLunchEvent = {
eventAt: Date | null
venue: string | null
notes: string | null
changeCutoffHours: number
}
/**
* Build the public, tokenized dish-pick URL for an external attendee.
* The token is a stateless HMAC signature (see external-lunch-token) carrying the
* externalId; `exp` is generous (event day + 1, or 30 days when no event date)
* so the link outlives the change deadline, which is enforced separately at write.
*/
export function buildExternalPickUrl(externalId: string, eventAt: Date | null): string {
const exp = eventAt
? Math.floor(eventAt.getTime() / 1000) + 86_400
: Math.floor(Date.now() / 1000) + 30 * 86_400
const token = signExternalLunchToken({ externalId, exp })
return `${getBaseUrl()}/lunch/pick/${token}`
}
/**
* Send (or resend) the dish-selection invite to one external attendee and stamp
* `inviteSentAt`. Throws if the external has no email or the email send fails;
* callers that must not fail (e.g. createExternal) wrap this in try/catch.
*/
export async function sendExternalDishInvite(
prisma: PrismaLike,
external: InvitableExternal,
event: InviteLunchEvent,
): Promise<void> {
if (!external.email) throw new Error('External attendee has no email address')
const changeDeadline = event.eventAt
? new Date(event.eventAt.getTime() - event.changeCutoffHours * 3_600_000)
: null
await sendExternalDishInviteEmail({
to: external.email,
name: external.name,
eventAt: event.eventAt,
venue: event.venue,
notes: event.notes,
changeDeadline,
pickUrl: buildExternalPickUrl(external.id, event.eventAt),
})
await prisma.externalAttendee.update({
where: { id: external.id },
data: { inviteSentAt: new Date() },
})
}

View File

@@ -0,0 +1,50 @@
import type { PrismaClient } from '@prisma/client'
/**
* Return all AttendingMember rows (with user) that:
* - belong to a CONFIRMED FinalistConfirmation in the given program
* - have NOT yet picked a lunch dish (no MemberLunchPick row OR pick row with pickedAt=null)
*
* This is extracted from the cron so it can be unit-tested independently and
* reused by the manual `sendReminders` admin action.
*
* Bug context: Prisma `lunchPick: { is: { pickedAt: null } }` only matches rows
* that EXIST but have pickedAt=null. Attendees with no pick row at all fall
* through. The correct filter is the OR form below.
*/
export async function selectUnpickedAttendees(
prisma: PrismaClient,
event: { id: string; programId: string },
) {
return prisma.attendingMember.findMany({
where: {
confirmation: {
project: { programId: event.programId },
status: 'CONFIRMED',
},
OR: [
{ lunchPick: { is: null } },
{ lunchPick: { is: { pickedAt: null } } },
],
},
include: { user: { select: { name: true, email: true } } },
})
}
/**
* Return external attendees for a LunchEvent that can still be chased for a dish
* pick: they have an email on file and have not yet been assigned a dish.
* Externals with no email (un-emailable) or an existing dish are excluded.
*/
export async function selectUnpickedExternals(
prisma: PrismaClient,
event: { id: string },
) {
return prisma.externalAttendee.findMany({
where: {
lunchEventId: event.id,
dishId: null,
AND: [{ email: { not: null } }, { email: { not: '' } }],
},
})
}

View File

@@ -0,0 +1,294 @@
import { afterAll, describe, expect, it } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestProgram,
createTestProject,
cleanupTestData,
uid,
} from '../helpers'
import { applicantRouter } from '../../src/server/routers/applicant'
// ─── shared helpers ────────────────────────────────────────────────────────
async function createApplicant() {
const id = uid('user')
return prisma.user.create({
data: {
id,
email: `${id}@test.local`,
name: 'Test Applicant',
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'ACTIVE',
},
})
}
/**
* Build a fully-confirmed finalist setup:
* - Program with visaStatusVisibleToMembers=true
* - Project with a TeamMember (LEAD)
* - CONFIRMED FinalistConfirmation
* - Hotel for the program
* - AttendingMember for the caller
* - FlightDetail for that attendee
* - VisaApplication GRANTED for that attendee
*/
async function buildConfirmedFinalist() {
const program = await createTestProgram({
name: `logistics-${uid()}`,
defaultAttendeeCap: 3,
})
await prisma.program.update({
where: { id: program.id },
data: { visaStatusVisibleToMembers: true },
})
const project = await createTestProject(program.id, {
title: 'Ocean Wave Project',
competitionCategory: 'STARTUP',
})
const user = await createApplicant()
await prisma.teamMember.create({
data: { projectId: project.id, userId: user.id, role: 'LEAD' },
})
const confirmation = await prisma.finalistConfirmation.create({
data: {
projectId: project.id,
category: 'STARTUP',
status: 'CONFIRMED',
deadline: new Date(Date.now() + 86_400_000),
token: `tok_${uid()}`,
confirmedAt: new Date(),
},
})
const attendee = await prisma.attendingMember.create({
data: {
confirmationId: confirmation.id,
userId: user.id,
needsVisa: true,
},
})
// Hotel for the program + HotelStay assigning the attendee to it
const hotel = await prisma.hotel.create({
data: {
programId: program.id,
name: 'Hotel Hermitage',
address: '11 Avenue de la Costa, Monaco',
link: 'https://example.com/hotel',
notes: 'Breakfast included.',
},
})
await prisma.hotelStay.create({
data: {
attendingMemberId: attendee.id,
hotelId: hotel.id,
roomNumber: '204',
checkInAt: new Date('2026-06-20T14:00:00Z'),
checkOutAt: new Date('2026-06-23T11:00:00Z'),
},
})
// FlightDetail
await prisma.flightDetail.create({
data: {
attendingMemberId: attendee.id,
arrivalFlightNumber: 'AF1234',
arrivalAirport: 'NCE',
arrivalAt: new Date('2026-06-20T10:00:00Z'),
departureFlightNumber: 'AF5678',
departureAirport: 'NCE',
departureAt: new Date('2026-06-23T15:00:00Z'),
status: 'CONFIRMED',
},
})
// VisaApplication GRANTED
await prisma.visaApplication.create({
data: {
attendingMemberId: attendee.id,
status: 'GRANTED',
nationality: 'French',
},
})
return { program, project, user, confirmation, attendee }
}
// ─── Task 1 tests ──────────────────────────────────────────────────────────
describe('applicant.getMyLogistics', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
for (const programId of programIds) {
await prisma.visaApplication.deleteMany({
where: { attendingMember: { confirmation: { project: { programId } } } },
})
await prisma.flightDetail.deleteMany({
where: { attendingMember: { confirmation: { project: { programId } } } },
})
await prisma.hotelStay.deleteMany({
where: { attendingMember: { confirmation: { project: { programId } } } },
})
await prisma.hotel.deleteMany({ where: { programId } })
await prisma.attendingMember.deleteMany({
where: { confirmation: { project: { programId } } },
})
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
it('returns hotel, flight, and visa for a confirmed finalist', async () => {
const { program, user } = await buildConfirmedFinalist()
programIds.push(program.id)
userIds.push(user.id)
const caller = createCaller(applicantRouter, {
id: user.id,
email: user.email,
role: 'APPLICANT',
})
const result = await caller.getMyLogistics()
expect(result).not.toBeNull()
expect(result!.projectTitle).toBe('Ocean Wave Project')
expect(result!.confirmationStatus).toBe('CONFIRMED')
expect(result!.hotel).not.toBeNull()
expect(result!.hotel!.name).toBe('Hotel Hermitage')
expect(result!.room).not.toBeNull()
expect(result!.room!.roomNumber).toBe('204')
expect(result!.myFlight).not.toBeNull()
expect(result!.myFlight!.arrivalFlightNumber).toBe('AF1234')
expect(result!.myFlight!.arrivalAirport).toBe('NCE')
expect(result!.visaVisible).toBe(true)
expect(result!.myVisa).not.toBeNull()
expect(result!.myVisa!.status).toBe('GRANTED')
})
it('returns null for a user who has no confirmed finalist project', async () => {
// user with no project at all
const nonFinalist = await createApplicant()
userIds.push(nonFinalist.id)
const caller = createCaller(applicantRouter, {
id: nonFinalist.id,
email: nonFinalist.email,
role: 'APPLICANT',
})
const result = await caller.getMyLogistics()
expect(result).toBeNull()
})
it('returns null when the confirmation is PENDING (not CONFIRMED)', async () => {
const program = await createTestProgram({
name: `logistics-pending-${uid()}`,
defaultAttendeeCap: 2,
})
programIds.push(program.id)
const project = await createTestProject(program.id, {
title: 'Pending Project',
competitionCategory: 'STARTUP',
})
const user = await createApplicant()
userIds.push(user.id)
await prisma.teamMember.create({
data: { projectId: project.id, userId: user.id, role: 'LEAD' },
})
await prisma.finalistConfirmation.create({
data: {
projectId: project.id,
category: 'STARTUP',
status: 'PENDING',
deadline: new Date(Date.now() + 86_400_000),
token: `tok_${uid()}`,
},
})
const caller = createCaller(applicantRouter, {
id: user.id,
email: user.email,
role: 'APPLICANT',
})
const result = await caller.getMyLogistics()
expect(result).toBeNull()
})
})
// ─── Task 2 tests ──────────────────────────────────────────────────────────
describe('applicant.updateMyVisaNationality', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
for (const programId of programIds) {
await prisma.visaApplication.deleteMany({
where: { attendingMember: { confirmation: { project: { programId } } } },
})
await prisma.flightDetail.deleteMany({
where: { attendingMember: { confirmation: { project: { programId } } } },
})
await prisma.hotelStay.deleteMany({
where: { attendingMember: { confirmation: { project: { programId } } } },
})
await prisma.hotel.deleteMany({ where: { programId } })
await prisma.attendingMember.deleteMany({
where: { confirmation: { project: { programId } } },
})
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
it('updates VisaApplication.nationality and persists it', async () => {
const { program, user, attendee } = await buildConfirmedFinalist()
programIds.push(program.id)
userIds.push(user.id)
const caller = createCaller(applicantRouter, {
id: user.id,
email: user.email,
role: 'APPLICANT',
})
const result = await caller.updateMyVisaNationality({ nationality: 'Kenya' })
expect(result).toEqual({ ok: true })
const updated = await prisma.visaApplication.findUnique({
where: { attendingMemberId: attendee.id },
})
expect(updated!.nationality).toBe('Kenya')
})
it('throws NOT_FOUND when the caller has no visible visa application', async () => {
const nonVisa = await createApplicant()
userIds.push(nonVisa.id)
const caller = createCaller(applicantRouter, {
id: nonVisa.id,
email: nonVisa.email,
role: 'APPLICANT',
})
await expect(
caller.updateMyVisaNationality({ nationality: 'Kenya' }),
).rejects.toMatchObject({ code: 'NOT_FOUND' })
})
})

Some files were not shown because too many files have changed in this diff Show More