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>
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>
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>
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>
Adds a sky-accented "Send Welcome / Reminder" button to the Notifications
grid in the round page, visible only on MENTORING rounds. Wires into
trpc.mentor.previewMentorshipWelcome / sendMentorshipWelcome via the
shared EmailPreviewDialog with optional custom note support.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sendMentorBulkAssignmentEmail now accepts optional teamMembers per project
and a customNote, forwards both to the template, switches to getBaseUrl(),
and returns Promise<boolean> (true on success, false on empty/error).
sendTeamMentorIntroductionEmail now accepts optional teammates and customNote,
forwards both to the template, switches to getBaseUrl(), and returns
Promise<boolean> (true on success, false on empty/error).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
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>
Three related bugs around the mentoring-round Projects tab:
1. Add Project to Round was unreachable on MENTORING rounds — the table swap
in the prior commit lost the button. Export AddProjectDialog from
project-states-table and render it inside MentoringProjectsTable with an
"Add" button in the filter row and a CTA in the empty state.
2. The "Assign Projects" quick action on the round overview linked to the
global pool with an opaque filter; on MENTORING rounds it now switches
to the Projects tab where the new Add Project button + auto-fill +
per-team picker all live. Non-mentoring rounds keep the old behavior.
3. mentor.assign and mentor.bulkAssign now refuse projects that aren't
enrolled in any MENTORING round (any status). The single-assign throws
BAD_REQUEST with a guidance message; the bulk path filters them out and
reports ineligibleProjectCount in the result so the UI can warn the
admin instead of silently skipping.
Tests: the multi-mentor-assignment suite now sets up a MENTORING round +
ProjectRoundState for each project it tests against, matching the new gate.
Email policy
- mentor.assign, mentor.bulkAssign, and autoAssignBulkForRound now suppress
outbound email entirely when the project's MENTORING round is still
ROUND_DRAFT. The MentorAssignment row is created (and in-app notifications
still fire), but notificationSentAt and teamIntroducedAt remain null so
activateRound can pick them up later.
- activateRound, when activating a MENTORING round, now does a coalesced
mentor-side email pass in addition to the existing team-side intro pass.
Every (mentorId) bucket of pending assignments in this round gets exactly
one combined email; the row stamps prevent duplicates on re-activation.
- The "send immediately" path is preserved for assignments made while the
round is already ROUND_ACTIVE — mentors and teams stay in the loop in
real time, but staging during draft is silent.
Per-project bulk UI
- The /admin/projects/[id]/mentor manual picker now has a checkbox column,
header select-all, and a primary-tinted action toolbar that appears when
one or more candidates are selected. Submitting calls mentor.bulkAssign
with the single projectId so the cartesian server path handles dedup,
coalesced emails, and team intros uniformly with the round-page bulk.
mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and
creates the cartesian product of (mentor, project) assignments. Existing
active (mentor, project) pairs are skipped per-pair, not per-project, so
choosing two mentors against a project that already has one of them still
adds the second.
Email coalescing stays one-per-mentor: each mentor receives a single email
listing only their own newly-assigned projects (not the union). Each touched
project still triggers a single team-introduction email when its MENTORING
round is ROUND_ACTIVE, listing all currently-active mentors on that team.
Dialog UI swaps the radio picker for a checkbox group with a removable chip
strip for selected mentors, a live preview of the assignment count
(mentors × projects = up to N), and a submit button that names both
counts. Toast on success reports total assignments created, projects
touched, pairs skipped, and how many mentor emails went out.
Round-page bulk-assign UI
- Checkboxes on every project row, header select-all, primary-tinted action
toolbar that appears when 1+ rows are selected with an "Assign mentor…"
CTA and Clear. Dialog lists the mentor pool with search (name/email/
country/expertise), load indicator, and a radio picker.
- Always-visible tip strip when nothing is selected explains the bulk flow
and offers a one-click "Select all N without a mentor" shortcut.
- New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns
one mentor to many projects in a transaction; idempotent on the per-pair
`(projectId, mentorId)` unique; per-project in-app notifications still
fire for each team.
- Mutation invalidates listMentoringProjects, getProjectsNeedingMentor,
getMentoringImportCandidates, getMentorPool, getRoundStats, project.list
so the page reflects the new state without a refresh.
Coalesced mentor emails
- New `sendMentorBulkAssignmentEmail` (single email listing every newly-
assigned project + workspace links) used by `mentor.bulkAssign` and
`mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow
now emails mentors at the end of the batch, one combined email per
mentor regardless of how many projects they received.
Team introduction emails when the round opens
- New `sendTeamMentorIntroductionEmail` lists every assigned mentor with
name + email and a link to the workspace, so teams can reach out
directly.
- `activateRound` (round-engine) fires the introduction for every project
in a MENTORING round that has active mentors when the round opens.
- `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also
fire the introduction immediately when the project's MENTORING round is
already ROUND_ACTIVE — so mentors added mid-round still reach the team.
- Idempotency via the new `MentorAssignment.teamIntroducedAt` column
(migration 20260526114936) — independent from `notificationSentAt` so
pre-existing mentor-side stamps don't suppress the team-side email.
Adding the MENTOR role from /admin/members/[id] only updated React state — the
AlertDialog "Add role" confirmation never called the server, so prod ended up
with zero users in MENTOR roles[] and /admin/mentors showed "No mentors yet".
The dialog now awaits updateUser.mutateAsync({ roles }) before closing.
Other corrections in the same area:
- DialogContent uses flex flex-col with max-h-[90vh] overflow-y-auto so tall
modals (e.g. Add Project to Round) scroll internally instead of overflowing
past their own rounded background.
- getProjectsNeedingMentor now matches autoAssignBulkForRound exactly: both
filter mentorAssignments by droppedAt: null and require
finalistConfirmation: CONFIRMED, so the toolbar count never exceeds what
auto-fill actually processes. The toolbar surfaces hasNoMentors /
hasNoEligible / count / all-assigned as distinct states instead of one
misleading "All eligible projects have a mentor" line.
- New per-team table (MentoringProjectsTable) replaces ProjectStatesTable on
the Projects tab of MENTORING rounds. Lists every project with its active
mentors (multi-mentor aware), filter pills, search, finalist-confirmation
badge, and a per-row link to /admin/projects/[id]/mentor for assigning.
- Applicant team page now lists ALL active mentors (PR8 Task 7) instead of
just mentorAssignments[0].
- Hard guard in src/lib/email.ts short-circuits sendEmail when NODE_ENV=test
or VITEST=true so test runs can never emit real notifications again.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mechanical sweep of 41 files via `perl -i -pe 's{\s+dark:[\w:/\[\]\.\-]+}{}g'`.
All dark: variants were paired with light-mode counterparts already; no
elements relied on a dark:-only style.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- stats.getApplicantNationalities procedure aggregates User.nationality
across team members of projects in the selected scope (round/program
/global)
- New Applicant Nationalities card on /admin/reports, top-10 with
Show all expansion, country names from the existing ISO map
- Handles the ~30% null case explicitly ("Not declared: N")
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Schema: MentorAssignment becomes M:N (composite unique on (projectId, mentorId)).
MentorFile re-scopes to projectId (team-wide); mentorAssignmentId becomes a
nullable audit FK with SetNull. New MentorChangeRequest model + status enum.
Behavior:
- mentor.assign stacks mentors per team; per-team assignment email fires
once per row (idempotent via notificationSentAt).
- mentor.requestChange / listChangeRequests / resolveChangeRequest provide
the change-request inbox; mentors are NOT notified, only admins.
- Workspace files re-scoped to project so all co-mentors and team members
share one file list and chat.
- New inline FilePreview support in the mentor workspace.
- mentor.getProjectMentors surfaces co-mentors on the mentor workspace.
Migration: hand-written, idempotent guards, two-phase backfill on
MentorFile.projectId. Verified against May 7 prod dump with rollback.sql.
PRE-DEPLOY: pull a fresh prod DB dump and re-run the dry-run before
applying the migration to prod (the May 7 snapshot may not include
mentors added since by another admin).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Eye toggle expands the row below to embed FilePreview from
@/components/shared/file-viewer (PDF iframe, image, video, Office docs)
- Download button uses explicit Content-Disposition: attachment via a
new `disposition` input on workspaceGetFileDownloadUrl
- getPresignedUrl learns `inline: true` and optional `response-content-type`
override so PDFs/images don't get force-downloaded by MinIO's default
- Eye button only renders for previewable mime types
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- multi-mentor-assignment.test.ts: stacking, P2002 dup-pair, per-team email
idempotency via notificationSentAt, requestChange/list/resolve auth +
conflict semantics
- mentor-file-scope.test.ts: schema invariant (projectId required, dropping
the originating assignment leaves the file in place via SetNull)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- /admin/projects/[id]/mentor renders all co-mentors as a list with per-row
Unassign (confirm dialog) and a stacking "Add a mentor" flow that no longer
hides when at least one mentor is assigned. Candidates and AI suggestions
filter out already-assigned mentors.
- Pending change-requests panel appears above the mentor list when there are
open requests for the project, with per-card Mark Resolved / Dismiss actions
routed through mentor.resolveChangeRequest (optional resolution note).
- MentoringRoundOverview gains a "Pending change requests" row showing the
PENDING count across the program; the Review link deep-links to the first
pending request's project mentor page.
- mentor.unassign now accepts { assignmentId } so the admin UI can target a
specific co-mentor (legacy { projectId }-only callers still work and remove
the most-recent assignment).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- /applicant/mentor renders all co-mentors as cards
- New "Request a mentor change" dialog opens a free-form reason + optional
per-mentor target; calls mentor.requestChange and shows admin-routed
confirmation toast
- Pending-request guard disables the button until the admin resolves
- Adds mentor.getProjectMentors({ projectId }) — returns all active
MentorAssignment rows for a project, authorized to any mentor on it
- Workspace page header surfaces "You + N co-mentor(s): names…" so each
mentor knows the team composition without having to ask the admin
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- mentor.requestChange: applicants/admins open a PENDING MentorChangeRequest
with a reason; one open request per (user, project) enforced
- mentor.listChangeRequests: admin-only inbox listing
- mentor.resolveChangeRequest: admin marks RESOLVED or DISMISSED with optional
resolution note
- sendMentorChangeRequestEmail: notifies all SUPER_ADMIN/PROGRAM_ADMIN users
when a request is opened (try/catch — never throws)
- Mentors are NOT notified of change requests, even after resolution
(per design decision in PR8 plan)
- Audit log entries for create + resolve; raw reason redacted from audit
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- MentorFile.projectId is the new access boundary; mentorAssignmentId stays
as informational audit FK (nullable).
- uploadFile derives projectId from the assignment; getFiles takes projectId
directly; deleteFile/addFileComment auth checks any mentor on the project
OR a project team member.
- HMAC upload token now binds to projectId (in addition to assignmentId).
- promoteFile reads file.projectId directly (no more mentorAssignment null
navigation).
- Removes 3 placeholder NOT_FOUND guards added in Task 4.
- mentor.assign no longer rejects on existing mentor; rejects only on
duplicate (projectId, mentorId) via P2002 catch.
- After successful create, sendMentorTeamAssignmentEmail fires once and
stamps MentorAssignment.notificationSentAt for idempotency.
- All existing behavior preserved: audit log, in-app notifications,
MENTORING round auto-transition.
- mentor.getSuggestions no longer short-circuits when a mentor is already
assigned — the suggestions list is now informational and the per-pair
unique constraint enforces correctness at assign time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Schema dropped @unique on MentorAssignment.projectId in PR8 Task 1 →
back-relation becomes a list. Mechanical rename of Prisma queries and
consumer accessors. Legacy single-mentor callers use [0] with a TODO for
PR8 Task 8 to surface the full list. mentor-workspace.ts is left as Task 5.
- routers (mentor, project, applicant, finalist, round) and smart-assignment
service: include/where/select keys renamed; `mentorAssignment: null` →
`mentorAssignments: { none: {} }`; `{ isNot: null }` → `{ some: {} }`.
- UI consumers (mentor + applicant pages): `project.mentorAssignment` →
`project.mentorAssignments[0]` with TODO markers.
- Tests: `findUnique({ projectId })` → `findFirst({ projectId })` since the
composite key now requires both projectId+mentorId. MentorFile.create gains
the new required projectId.
- Workspace endpoints in mentor.ts now guard null mentorAssignmentId until
Task 5 re-scopes them to project.
- finalist.unconfirm now cascades to ALL active mentor assignments.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fires when a mentor is added to a specific project team — distinct from the
one-time onboarding email keyed by User.mentorOnboardingSentAt. Idempotency
for this new email is enforced at the call site in Task 4 via
MentorAssignment.notificationSentAt. Wrapped in try/catch — never throws.
Tested against the 2026-05-07 prod dump: restore → forward → rollback restores
the schema to its pre-migration state. Safe to run only BEFORE any project
gets a second mentor — re-adding UNIQUE(projectId) will fail otherwise
(intended safety signal).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous Additional Roles grid laid every role option out as a row of
checkboxes regardless of assignment, which made unchecked roles look like
roles the user already had — admins almost toggled the wrong role on the
wrong user (e.g. nearly granting JURY_MEMBER when looking at an
AWARD_MASTER).
New layout shows only the roles a user actually has, as removable badges
with an X. A "Manage roles" dropdown next to them surfaces the full role
list as DropdownMenuCheckboxItems (assigned ones are checked, the
primary role is excluded). Toggling any item opens an AlertDialog with
add/remove-specific copy that names the user and the dashboard being
granted/revoked, so the click is impossible to misread.
The change is staged into local additionalRoles state — same flow as
before — and persisted on Save. Modal copy spells this out so the admin
knows the action isn't applied until they click Save below.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The original generateAccessLink branched on user state and minted either
an invite URL (forces password setup) or a reset URL (forces password
change). Both required the user to set/change a password — fine for new
users, painful for tech-illiterate sponsor jurors who already have a
working password and just need a fresh login because their JWT went
stale or their email is bouncing.
This adapts the existing invite-token flow to behave as a magic-login
when the user already has a password:
- auth.ts credentials.authorize: only set mustSetPassword=true if the
user has no passwordHash. Users who already set one keep it, the
invite token is consumed, JWT is issued with their current role,
they're signed in.
- accept-invite/page.tsx: redirect to / after accept (was hardcoded
to /set-password). The middleware already enforces the
/set-password detour when mustSetPassword is true, so users who
need it still land there; everyone else routes by role.
- generateAccessLink: drop the reset-password branch. Always emits an
/accept-invite URL. The flow naturally adapts: setup for new users,
magic-login for active ones. Audit log records which behavior fired
(kind: 'setup' | 'magic_login').
- dialog copy: clearer description for each kind.
Net behavior: Didier (active, has password, stale JWT after role
migration) clicks his link → instant login on /jury, password preserved.
Magali (no password yet) clicks hers → /set-password → onboarding.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a "Copy Access Link" button on the member detail page that mints a
one-time URL the admin can share over Slack, WhatsApp, or any other
channel. Solves the "we sent them an invite three weeks ago and it
silently dropped into spam" failure mode that left jurors stranded.
Server: user.generateAccessLink (adminProcedure) inspects the target
user's state and picks the right flow:
- INVITED / NONE / mustSetPassword / no password ever set → invite-flow
URL (/accept-invite?token=…); the existing flow takes them through
accept → set password → onboarding without further admin help.
- Active user with a password → password-reset URL
(/reset-password?token=…); they pick a new password and middleware
bounces them to onboarding if it's still pending.
Both flows already exist; this just exposes a way to mint a fresh token
without sending an email. The token has a 24h hard expiry and is consumed
on successful completion of the flow, so a leaked or screenshot link
can't be replayed against a different user later in the day. Each
generation is audit-logged with the admin's id, the target user's id +
email, and the link kind.
UI: button next to Resend Invite on /admin/members/[id]; opens a dialog
with a read-only input pre-selected, a one-click copy button, expiry
timestamp, and a warning not to paste in public channels.
Side benefit: users like Didier who have stale JWTs from a recent role
change can use a fresh access link to force a re-login that picks up
their updated role.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous regex against `prisma migrate status` output silently drifted
out of sync with Prisma 6's wording, so today's failed migration on prod
was never auto-resolved — the container crash-looped and required a
manual DELETE on _prisma_migrations to recover.
Truth lives in the table: a row with `finished_at IS NULL AND
rolled_back_at IS NULL` is an unresolved failure. Query that directly via
the Prisma client (already shelled out for the user-count check below)
and loop until none remain (with a 5-iteration safety bound).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 20260507151706_drop_award_master_role migration failed on prod with
'default for column "roles" cannot be cast automatically to type
"UserRole_new"[]'. Postgres won't auto-cast the @default([]) binding
through an enum-type swap. Same DROP DEFAULT / SET DEFAULT dance the
singular `role` column already had.
The original migration ran in a transaction that fully rolled back, so
the DB is unchanged — the fixed migration can be applied as-is once the
failed record is resolved (DELETE FROM _prisma_migrations WHERE
migration_name='20260507151706_drop_award_master_role').
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The AWARD_MASTER role split sponsor jurors into a parallel UI that hid
project files (only showed when the award was anchored to an evaluation
round) and duplicated the jury voting path with no real difference in
authority — tie-break and finalize were already governed by AwardJuror.isChair
regardless of the user's global role. Inviting a juror via the award page
defaulted to AWARD_MASTER, randomly fragmenting jury panels.
This collapses the role into JURY_MEMBER + isChair:
- specialAward.getMyAwardDetail now returns evaluation scores, chair
visibility into other jurors' votes, and juror roster
- specialAward.submitVote accepts an optional justification per vote
- specialAward.confirmWinner moves from awardMasterProcedure to
protectedProcedure (juror+chair check inside)
- bulkInviteJurors creates JURY_MEMBER accounts and, when the award has
a juryGroupId, also adds them to that JuryGroup so they appear on
the round-page jury panel
- jury award page renders justification, eval-score badges, and a
chair tools panel with vote tally + finalize-winner CTA
- juryGroup.list includes attached SpecialAwards; the jury-list UI
shows a trophy pill alongside round pills
- (award-master) route group, awardMasterProcedure, AWARD_MASTER role
enum value, and AWARD_MASTER_DECISION decisionMode are deleted
- migration demotes any residual AWARD_MASTER users to JURY_MEMBER and
recreates the UserRole enum without the value
Coup de Coeur on prod: Didier (the sponsor juror added today as
AWARD_MASTER by the buggy invite form) was migrated to JURY_MEMBER and
attached to the existing "Coup de Coeur" JuryGroup; the SpecialAward
itself was linked to that group (juryGroupId was NULL).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>