Commit Graph

104 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
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
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
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
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
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
e4f13aaed4 feat(final-docs): auto pre-deadline reminder cron 2026-06-09 16:00:42 +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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
829b082912 feat(mentor): admin preview + send mentorship welcome/reminder email
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:40:38 +02:00
Matt
9d3ed1cc64 test(email): cover customNote escaping + text-path for mentorship templates 2026-06-01 16:28:32 +02:00
Matt
a973b1316c feat(email): instructions + contact emails + optional note in mentorship templates
Export getMentorBulkAssignmentTemplate and getTeamMentorIntroductionTemplate,
adding an always-on instructions block, optional team-member/teammate contact
lists, and an optional custom note to both. Covers TDD with 4 new unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 16:24:31 +02:00
Matt
03526fca97 fix(mentor): defer in-app-notification emails when mentoring round is draft
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m14s
Mentor-assignment flows (mentor.assign, autoAssign, bulkAssign,
bulkAutoAssign, autoAssignBulkForRound) call createNotification and
notifyProjectTeam for MENTEE_ASSIGNED / MENTOR_ASSIGNED. Both
notification types have NotificationEmailSetting.sendEmail = true, so
the notification system fires its own styled email in addition to the
explicit mentor-team / coalesced emails on the same code path. The
earlier defer-emails-until-round-open fix only gated the explicit
sendMentorBulkAssignmentEmail / sendMentorTeamAssignmentEmail calls;
this parallel email path kept firing immediately at every assignment.

Result on prod 2026-05-26: Camille Lopez (assigned to 9 projects via
two bulk_assigns) received 7 emails at 15:04 + 1 at 15:32 from the
notification-system path during draft, plus 1 coalesced email at the
18:20 round activation = 9 sends instead of 1. Every PEARL team
member (and equivalents on other teams) received 3 emails for the
same reason.

Fix
- Add `skipEmail?: boolean` to CreateNotificationParams,
  createNotification, createBulkNotifications, and (via spread)
  notifyProjectTeam. When true the in-app notification row still
  fires but the parallel email send is suppressed; the coalesced
  mentor email and team intro at activateRound time remain the
  single source of email truth.
- Wire it up in every mentor-assignment site: compute the existing
  shouldDeferEmailsForProject gate once before the createNotification
  / notifyProjectTeam calls and pass `skipEmail: deferThisEmail`.
  bulkAssign precomputes draftProjectIds for the whole batch.
  autoAssignBulkForRound uses the round's status directly.
- New regression suite (mentor-email-deferral.test.ts, 3 cases):
  vi.mocks @/lib/email, asserts zero outbound sends when round is
  ROUND_DRAFT, confirms in-app notification rows still get written,
  and re-verifies the ACTIVE-round path still emails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:12:41 +02:00