Compare commits

..

44 Commits

Author SHA1 Message Date
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
Matt
ed4948cc3d Merge: mentorship comms + welcome/reminder email
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m8s
- Email-all-team-members button for mentors
- Upgraded round-open emails (instructions + contact addresses)
- Admin re-sendable welcome/reminder blast with live preview
- New tRPC: mentor.previewMentorshipWelcome / sendMentorshipWelcome

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:04:36 +02:00
Matt
bd05aaa87d feat(mentor): email all team members button on project detail
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:50:00 +02:00
Matt
0d6f71b9e1 feat(admin): send mentorship welcome/reminder button on mentoring rounds
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>
2026-06-01 16:46:58 +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
32116dac75 feat(mentor): round-open emails now carry team-member contacts
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:33:27 +02:00
Matt
0e221c3916 feat(email): mentorship senders forward contacts/note, return success
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>
2026-06-01 16:29:47 +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
5a9821807a docs(mentor): implementation plan for mentorship comms + welcome email
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:20:05 +02:00
Matt
d57495be15 docs(mentor): design spec for mentorship comms + welcome/reminder email
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:08:46 +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
Matt
61dfc608cd fix(mentor): restore Add Project on mentoring rounds + gate mentor assignment
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m15s
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.
2026-05-26 15:20:01 +02:00
Matt
c4f7216bc1 feat(mentor): defer all assignment emails until round opens + per-project bulk UI
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m7s
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.
2026-05-26 14:48:38 +02:00
Matt
cb2a864b7f feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m27s
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.
2026-05-26 14:25:41 +02:00
Matt
195fc787a9 feat(mentor): bulk assignment + coalesced emails + team intros on round open
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m53s
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.
2026-05-26 14:04:32 +02:00
Matt
921019aaa4 fix(mentor): unbreak the mentor pipeline end-to-end
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m42s
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>
2026-05-26 13:01:05 +02:00
Matt
5b99d6a530 refactor(ui): strip all dark: Tailwind classes (single-theme product)
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m17s
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>
2026-05-22 18:45:42 +02:00
Matt
6969b9c2bc chore(deps): drop next-themes; remove ThemeProvider + theme toggle UI 2026-05-22 18:43:25 +02:00
Matt
3bc9c11a51 merge: PR10 — applicant nationality stats card 2026-05-22 18:42:51 +02:00
Matt
8d4b62a602 feat(reports): applicant nationality breakdown card with scope filter (PR10)
- 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>
2026-05-22 18:38:52 +02:00
Matt
f64e68e751 merge: PR8 — multi-mentor per team + change-requests + inline previews
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>
2026-05-22 18:26:37 +02:00
Matt
48e48f058d feat(mentor-workspace): inline document preview matching applicant docs pattern
- 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>
2026-05-22 18:26:20 +02:00
Matt
ec92b03006 test(mentor): cover multi-mentor stacking + change-request procedures (PR8 Task 10)
- 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>
2026-05-22 17:20:01 +02:00
Matt
349671f37c merge: PR8 Task 8 — admin multi-mentor UI + change-request inbox 2026-05-22 17:13:02 +02:00
Matt
4f444a1baa merge: PR8 Task 7 — applicant mentor list + request-change dialog 2026-05-22 17:12:58 +02:00
Matt
d47db17027 merge: PR8 Task 9 — mentor co-mentor visibility 2026-05-22 17:12:54 +02:00
Matt
83e950bb67 feat(admin): multi-mentor stacking UI + change-request inbox (PR8 Task 8)
- /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>
2026-05-22 17:11:31 +02:00
Matt
ba115f71a0 feat(applicant): mentor list + request-change dialog (PR8 Task 7)
- /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
2026-05-22 17:09:06 +02:00
Matt
d440b5f274 feat(mentor): show co-mentors on workspace page (PR8 Task 9)
- 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>
2026-05-22 17:07:11 +02:00
Matt
ee47c0305f feat(mentor): add change-request procedures + admin email notification
- 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>
2026-05-22 16:59:23 +02:00
Matt
3a1eb149b6 feat(mentor-workspace): re-scope files from assignment to project for team-wide visibility
- 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.
2026-05-22 16:53:07 +02:00
Matt
a5ad11a1b5 feat(mentor): allow stacking mentors per team; send per-team assignment email
- 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>
2026-05-22 16:38:14 +02:00
Matt
66110598a0 refactor(schema-cascade): rename Project.mentorAssignment → mentorAssignments
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>
2026-05-22 16:37:37 +02:00
Matt
9152ebb399 feat(email): add sendMentorTeamAssignmentEmail for per-team mentor notifications
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.
2026-05-22 16:16:28 +02:00
Matt
a26e486ab5 chore(migration): include manual rollback.sql for PR8 multi-mentor
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>
2026-05-22 16:13:28 +02:00
Matt
e89dca24c3 feat(schema): multi-mentor per team + change-requests + per-assignment email field
- MentorAssignment: drop projectId @unique -> composite (projectId, mentorId)
- MentorAssignment: add notificationSentAt for idempotent per-team email
- MentorFile: add projectId (primary scope); mentorAssignmentId becomes nullable audit FK
- MentorChangeRequest: new model + status enum
- Migration hand-written with IF EXISTS guards (safe for docker-entrypoint retry)
2026-05-22 16:05:25 +02:00
92 changed files with 7679 additions and 1049 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,122 @@
# Mentorship Communications & Welcome/Reminder Email — Design
- **Date:** 2026-06-01
- **Status:** Approved (pending spec review)
- **Author:** Matt + Claude
- **Topic:** Make mentor↔team contact effortless and add a re-sendable, instructional "welcome/reminder" email for mentoring rounds.
## Context
MOPC already has a working mentorship feature:
- **Two-way in-app messaging** exists (`MentorMessage` model; `WorkspaceChat` + `MentorChat` components; `trpc.mentor.sendMessage` / `getMessages` and `trpc.applicant.sendMentorMessage` / `getMentorMessages`). Mentors are auto-notified when applicants write.
- **Contact emails are already visible**: mentors see each team member's email as individual `mailto:` links (`src/app/(mentor)/mentor/projects/[id]/page.tsx`); applicants see their mentor's name+email (`src/app/(applicant)/applicant/mentor/page.tsx`) and teammates' emails (`src/app/(applicant)/applicant/team/page.tsx`).
- **Round-open auto emails already fire**: flipping a `MENTORING` round draft→active sends a coalesced *"you've been assigned to N projects"* email to each mentor (`getMentorBulkAssignmentTemplate` / `sendMentorBulkAssignmentEmail`) and a *"meet your mentors"* intro to each team (`getTeamMentorIntroductionTemplate` / `sendTeamMentorIntroductionEmail`). These are one-time, gated by `MentorAssignment.notificationSentAt` and `MentorAssignment.teamIntroducedAt` (`src/server/services/round-engine.ts`).
Two gaps remain:
1. There is **no single "email all team members"** affordance for mentors — only per-person `mailto:` links.
2. The round-open emails **don't explain how to use the mentorship features**, and there is **no way to re-send** them later as a reminder.
## Goals
- A mentor can email their whole team in one click (opens their mail client, all members in `To:`).
- The round-open assignment emails are **upgraded in place** to include (a) the relevant contact emails and (b) how-to-use-the-mentorship-features instructions.
- An admin can **re-send** that same email on demand (a "welcome/reminder" blast) to all mentors + teams in a mentoring round, with an optional custom note.
- The admin can **preview** the exact email (mentor + team versions) before sending.
## Non-goals
- No new in-app messaging surfaces (the chat already exists).
- No new email-provider infrastructure (reuse `src/lib/email.ts` wrapper, helpers, throttling, `NotificationLog`).
- No mentors-only / teams-only targeting toggle for v1 — the reminder sends to **both** audiences. (Can be added later if needed.)
## Feature 1 — Mentor "Email all team members" button
- **Location:** `src/app/(mentor)/mentor/projects/[id]/page.tsx`, in the existing Team Members card, alongside the per-member `mailto:` links.
- **Behavior:** builds `mailto:<comma-joined emails>?subject=...` with **all active team members in `To:`** (per decision), subject pre-filled `MOPC Mentorship — {project title}`. Clicking opens the mentor's default mail app.
- **Edge cases:** filter out blank/missing emails defensively (schema makes `User.email` required+non-null, but be safe); hide the button when the team has zero emailable members.
- **Scope:** pure client-side; no backend changes.
## Feature 2 — Unified mentorship welcome/reminder email
### Decision: upgrade in place, don't duplicate
Rather than send a second email on round-open, the **existing** two templates are enhanced so they carry the instructions + contact emails. The same template code is reused by both trigger paths below. One email per audience; one source of truth.
### Content — Mentor version (coalesced per mentor, across their projects in the round)
- Greeting by mentor name.
- Optional custom note (rendered in an info box near the top) — only present on the manual reminder path.
- For **each** assigned project: project title (linked) + the **team members listed with name + email**.
- "How to mentor on MOPC" instructions block: where the workspace chat lives, file sharing, the mentor dashboard.
- CTA → Mentor Dashboard.
### Content — Team version (per project)
- Greeting by recipient name.
- Optional custom note (info box) — manual path only.
- The assigned **mentor(s) listed with name + email**.
- The team's **own members listed with email** (per decision: include teammates too).
- "How to work with your mentor" instructions block: where the in-app chat is, how to reach the mentor, what to expect.
- CTA → mentoring page.
Both reuse `getEmailWrapper()` and existing helpers (`sectionTitle`, `paragraph`, `ctaButton`, `infoBox`, `escapeHtml`) for consistent branding.
### Trigger path A — auto on round-open (existing flow, upgraded content)
- `src/server/services/round-engine.ts` draft→active flow keeps its one-time semantics (`notificationSentAt` / `teamIntroducedAt` gating) and coalescing.
- It now passes the additional data the upgraded templates need: team-member name+email for the mentor email, and mentor name+email + teammate emails for the team email.
- No custom note on this path.
### Trigger path B — manual reminder button (admin, on demand)
- New `adminProcedure`: `mentor.sendMentorshipWelcome({ roundId, customNote?: string })`.
- Resolves **all current** active assignments for the round (`droppedAt: null`) → groups by mentor → sends mentor emails; resolves all projects with assignments → sends team emails to all members.
- **Ignores** `notificationSentAt` / `teamIntroducedAt` (deliberate re-send). Does **not** mutate those flags.
- Throttled + fire-and-forget like existing bulk sends; writes `NotificationLog` rows + a `DecisionAuditLog`/audit entry.
- Returns counts: `{ mentorCount, teamMemberCount, teamCount }` for the success toast.
### Preview
- New query: `mentor.previewMentorshipWelcome({ roundId, customNote?: string })``{ mentor: { subject, html }, team: { subject, html } }`.
- Calls the **same** template functions used by the real send.
- Picks a representative recipient: first mentor with assignments + first project/team in the round. If the round has none yet, returns clearly-labeled sample-data output so the layout is still previewable.
- Rendered in the send dialog inside a sandboxed `<iframe srcDoc={html}>` (isolates email CSS from the app), with **Mentor / Team** sub-tabs. The custom-note textarea updates the preview live (debounced).
### Admin UI
- New component `src/components/admin/round/send-mentorship-welcome-button.tsx`:
- Lives in the round detail page's **Notifications** section (`src/app/(admin)/admin/rounds/[roundId]/page.tsx`, near `NotifyAdvancedButton` / `NotifyRejectedButton` / `BulkInviteButton`), rendered **only when the round is `MENTORING`**.
- Opens a dialog: recipient summary ("N mentors · M team members across K teams"), optional custom-note textarea, live Preview (Mentor/Team tabs), and a Send button with confirmation.
## Files touched
| File | Change |
|---|---|
| `src/app/(mentor)/mentor/projects/[id]/page.tsx` | Add "Email all team members" button (mailto, all in To:) |
| `src/lib/email.ts` | Enhance `getMentorBulkAssignmentTemplate` + `getTeamMentorIntroductionTemplate` (contacts, instructions, optional `customNote`); update `sendMentorBulkAssignmentEmail` / `sendTeamMentorIntroductionEmail` signatures + all call sites |
| `src/server/services/round-engine.ts` | Pass team-member/mentor emails into upgraded templates on round-open |
| `src/server/routers/mentor.ts` | New `sendMentorshipWelcome` (adminProcedure) + `previewMentorshipWelcome` (adminProcedure query) |
| `src/components/admin/round/send-mentorship-welcome-button.tsx` (new) | Dialog: counts, custom note, live iframe preview, send |
| `src/app/(admin)/admin/rounds/[roundId]/page.tsx` | Wire the button into the Notifications section, gated to mentoring rounds |
## Implementation ordering note
Build the templates first and render both to standalone `.html` files (and/or screenshots) for copy review **before** wiring the send path — gives an early visual check with zero throwaway work.
## Testing
- **Template unit tests** (`src/lib/email.ts` fns return `{ subject, html, text }`, easy to assert): mentor email contains each team member's email + instructions block; team email contains mentor email(s) + teammate emails + instructions; custom note appears when passed, absent when not.
- **tRPC test** for `sendMentorshipWelcome` on a seeded mentoring round: correct recipient resolution and returned counts; does not flip the one-time flags.
- **tRPC test** for `previewMentorshipWelcome`: returns non-empty mentor + team HTML for a seeded round; sample-data fallback for an empty round.
## Decisions (resolved during brainstorming)
1. Upgrade existing intro emails in place (single source of truth), reused by both auto-open and the manual reminder; fallback would have been a standalone manual-only blast.
2. Tailored content per audience (mentor vs team), **with contact emails embedded** in the relevant spot.
3. Manual reminder: fixed branded template **+ optional custom note**.
4. "Email all" button: **all members in `To:`**.
5. Team email includes **both** the mentor's email and teammates' emails.
6. Manual reminder sends to **both** audiences (no per-audience toggle in v1).
7. Preview via an in-app button (live, real-data, iframe) rather than pasted static HTML.

11
package-lock.json generated
View File

@@ -61,7 +61,6 @@
"motion": "^11.15.0", "motion": "^11.15.0",
"next": "^15.1.0", "next": "^15.1.0",
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"next-themes": "^0.4.6",
"nodemailer": "^7.0.7", "nodemailer": "^7.0.7",
"openai": "^6.16.0", "openai": "^6.16.0",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
@@ -12143,16 +12142,6 @@
} }
} }
}, },
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/next/node_modules/postcss": { "node_modules/next/node_modules/postcss": {
"version": "8.4.31", "version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",

View File

@@ -75,7 +75,6 @@
"motion": "^11.15.0", "motion": "^11.15.0",
"next": "^15.1.0", "next": "^15.1.0",
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"next-themes": "^0.4.6",
"nodemailer": "^7.0.7", "nodemailer": "^7.0.7",
"openai": "^6.16.0", "openai": "^6.16.0",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",

View File

@@ -0,0 +1,78 @@
-- Hand-written migration for PR8 (multi-mentor per team).
--
-- All DDL guarded with IF EXISTS / IF NOT EXISTS so the docker-entrypoint
-- retry loop is safe to re-run. No regex (the 2026-05-07 prod incident was
-- caused by Prisma 6 generating regex-based DDL that Postgres rejected).
-- No BEGIN/COMMIT blocks — Prisma wraps the migration in a transaction.
-- Phase 1: MentorAssignment — drop unique, add composite, add notification field
ALTER TABLE "MentorAssignment" DROP CONSTRAINT IF EXISTS "MentorAssignment_projectId_key";
DROP INDEX IF EXISTS "MentorAssignment_projectId_key";
CREATE UNIQUE INDEX IF NOT EXISTS "MentorAssignment_projectId_mentorId_key"
ON "MentorAssignment"("projectId", "mentorId");
CREATE INDEX IF NOT EXISTS "MentorAssignment_projectId_idx"
ON "MentorAssignment"("projectId");
ALTER TABLE "MentorAssignment" ADD COLUMN IF NOT EXISTS "notificationSentAt" TIMESTAMP(3);
-- Phase 2: MentorFile — re-scope to project (two-phase backfill)
ALTER TABLE "MentorFile" ADD COLUMN IF NOT EXISTS "projectId" TEXT;
UPDATE "MentorFile" mf
SET "projectId" = ma."projectId"
FROM "MentorAssignment" ma
WHERE mf."mentorAssignmentId" = ma."id"
AND mf."projectId" IS NULL;
ALTER TABLE "MentorFile" ALTER COLUMN "projectId" SET NOT NULL;
ALTER TABLE "MentorFile" DROP CONSTRAINT IF EXISTS "MentorFile_projectId_fkey";
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE INDEX IF NOT EXISTS "MentorFile_projectId_idx" ON "MentorFile"("projectId");
-- Phase 2b: Make MentorFile.mentorAssignmentId nullable + switch its FK to SetNull
ALTER TABLE "MentorFile" ALTER COLUMN "mentorAssignmentId" DROP NOT NULL;
ALTER TABLE "MentorFile" DROP CONSTRAINT IF EXISTS "MentorFile_mentorAssignmentId_fkey";
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_mentorAssignmentId_fkey"
FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Phase 3: MentorChangeRequest table
-- Postgres < 14 doesn't support CREATE TYPE ... IF NOT EXISTS, so wrap in a
-- DO block that swallows duplicate_object errors (idempotent for re-runs).
DO $$ BEGIN
CREATE TYPE "MentorChangeRequestStatus" AS ENUM ('PENDING', 'RESOLVED', 'DISMISSED');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
CREATE TABLE IF NOT EXISTS "MentorChangeRequest" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"targetAssignmentId" TEXT,
"requestedByUserId" TEXT,
"reason" TEXT NOT NULL,
"status" "MentorChangeRequestStatus" NOT NULL DEFAULT 'PENDING',
"resolvedByUserId" TEXT,
"resolvedAt" TIMESTAMP(3),
"resolutionNote" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MentorChangeRequest_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "MentorChangeRequest" DROP CONSTRAINT IF EXISTS "MentorChangeRequest_projectId_fkey";
ALTER TABLE "MentorChangeRequest" ADD CONSTRAINT "MentorChangeRequest_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "MentorChangeRequest" DROP CONSTRAINT IF EXISTS "MentorChangeRequest_targetAssignmentId_fkey";
ALTER TABLE "MentorChangeRequest" ADD CONSTRAINT "MentorChangeRequest_targetAssignmentId_fkey"
FOREIGN KEY ("targetAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "MentorChangeRequest" DROP CONSTRAINT IF EXISTS "MentorChangeRequest_requestedByUserId_fkey";
ALTER TABLE "MentorChangeRequest" ADD CONSTRAINT "MentorChangeRequest_requestedByUserId_fkey"
FOREIGN KEY ("requestedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "MentorChangeRequest" DROP CONSTRAINT IF EXISTS "MentorChangeRequest_resolvedByUserId_fkey";
ALTER TABLE "MentorChangeRequest" ADD CONSTRAINT "MentorChangeRequest_resolvedByUserId_fkey"
FOREIGN KEY ("resolvedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX IF NOT EXISTS "MentorChangeRequest_projectId_idx" ON "MentorChangeRequest"("projectId");
CREATE INDEX IF NOT EXISTS "MentorChangeRequest_status_idx" ON "MentorChangeRequest"("status");
CREATE INDEX IF NOT EXISTS "MentorChangeRequest_targetAssignmentId_idx" ON "MentorChangeRequest"("targetAssignmentId");

View File

@@ -0,0 +1,23 @@
-- PR8 rollback SQL (manual, only safe BEFORE any project has >1 mentor)
-- Reverses 20260522155652_multi_mentor_per_team
-- MentorChangeRequest: drop new table + enum
DROP TABLE IF EXISTS "MentorChangeRequest";
DROP TYPE IF EXISTS "MentorChangeRequestStatus";
-- MentorFile: drop projectId scope + restore mentorAssignmentId as required Cascade
ALTER TABLE "MentorFile" DROP CONSTRAINT IF EXISTS "MentorFile_projectId_fkey";
DROP INDEX IF EXISTS "MentorFile_projectId_idx";
ALTER TABLE "MentorFile" DROP COLUMN IF EXISTS "projectId";
-- Restoring NOT NULL is safe only if no rows have NULL mentorAssignmentId (true unless multi-mentor assignments were dropped post-migration)
ALTER TABLE "MentorFile" ALTER COLUMN "mentorAssignmentId" SET NOT NULL;
ALTER TABLE "MentorFile" DROP CONSTRAINT IF EXISTS "MentorFile_mentorAssignmentId_fkey";
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_mentorAssignmentId_fkey"
FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- MentorAssignment: restore projectId @unique + drop new fields
DROP INDEX IF EXISTS "MentorAssignment_projectId_mentorId_key";
DROP INDEX IF EXISTS "MentorAssignment_projectId_idx";
ALTER TABLE "MentorAssignment" DROP COLUMN IF EXISTS "notificationSentAt";
-- Re-adding UNIQUE will FAIL if any project has >1 mentor (intended safety signal)
ALTER TABLE "MentorAssignment" ADD CONSTRAINT "MentorAssignment_projectId_key" UNIQUE ("projectId");

View File

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

View File

@@ -118,7 +118,6 @@ enum NotificationChannel {
NONE NONE
} }
enum PartnerVisibility { enum PartnerVisibility {
ADMIN_ONLY ADMIN_ONLY
JURY_VISIBLE JURY_VISIBLE
@@ -133,7 +132,6 @@ enum PartnerType {
OTHER OTHER
} }
// ============================================================================= // =============================================================================
// COMPETITION / ROUND ENGINE ENUMS // COMPETITION / ROUND ENGINE ENUMS
// ============================================================================= // =============================================================================
@@ -171,7 +169,6 @@ enum ProjectRoundStateValue {
WITHDRAWN WITHDRAWN
} }
enum CapMode { enum CapMode {
HARD HARD
SOFT SOFT
@@ -428,6 +425,10 @@ model User {
// Grand-finale logistics // Grand-finale logistics
finalistAttendances AttendingMember[] finalistAttendances AttendingMember[]
// Mentor change requests
mentorChangeRequestsRequested MentorChangeRequest[] @relation("MentorChangeRequester")
mentorChangeRequestsResolved MentorChangeRequest[] @relation("MentorChangeResolver")
@@index([role]) @@index([role])
@@index([status]) @@index([status])
} }
@@ -629,7 +630,9 @@ model Project {
assignments Assignment[] assignments Assignment[]
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull) submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
teamMembers TeamMember[] teamMembers TeamMember[]
mentorAssignment MentorAssignment? mentorAssignments MentorAssignment[]
mentorFiles MentorFile[]
mentorChangeRequests MentorChangeRequest[]
filteringResults FilteringResult[] filteringResults FilteringResult[]
awardEligibilities AwardEligibility[] awardEligibilities AwardEligibility[]
awardVotes AwardVote[] awardVotes AwardVote[]
@@ -1270,7 +1273,7 @@ model TeamMember {
model MentorAssignment { model MentorAssignment {
id String @id @default(cuid()) id String @id @default(cuid())
projectId String @unique // One mentor per project projectId String // Team can have multiple mentors; uniqueness enforced via composite below
mentorId String // User with MENTOR role or expertise mentorId String // User with MENTOR role or expertise
// Assignment tracking // Assignment tracking
@@ -1278,6 +1281,16 @@ model MentorAssignment {
assignedAt DateTime @default(now()) assignedAt DateTime @default(now())
assignedBy String? // Admin who assigned assignedBy String? // Admin who assigned
// Per-assignment email idempotency: stamped once the MENTOR-side notification
// email has been sent (the "you've been assigned a project" email to the mentor).
notificationSentAt DateTime?
// Stamped once the TEAM has been introduced to this mentor (the "meet your
// mentor" email with mentor contact info). Fired by `activateRound` for
// MENTORING rounds and by mentor.assign when the project's MENTORING round
// is already ROUND_ACTIVE. Independent from notificationSentAt above.
teamIntroducedAt DateTime?
// AI assignment metadata // AI assignment metadata
aiConfidenceScore Float? aiConfidenceScore Float?
expertiseMatchScore Float? expertiseMatchScore Float?
@@ -1304,11 +1317,47 @@ model MentorAssignment {
milestoneCompletions MentorMilestoneCompletion[] milestoneCompletions MentorMilestoneCompletion[]
messages MentorMessage[] messages MentorMessage[]
files MentorFile[] files MentorFile[]
changeRequests MentorChangeRequest[] @relation("MentorChangeRequestTarget")
@@unique([projectId, mentorId])
@@index([projectId])
@@index([mentorId]) @@index([mentorId])
@@index([method]) @@index([method])
} }
// =============================================================================
// MENTOR CHANGE REQUESTS
// =============================================================================
enum MentorChangeRequestStatus {
PENDING
RESOLVED
DISMISSED
}
model MentorChangeRequest {
id String @id @default(cuid())
projectId String
targetAssignmentId String? // Optional: a specific co-mentor the request is about
requestedByUserId String?
reason String @db.Text
status MentorChangeRequestStatus @default(PENDING)
resolvedByUserId String?
resolvedAt DateTime?
resolutionNote String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
targetAssignment MentorAssignment? @relation("MentorChangeRequestTarget", fields: [targetAssignmentId], references: [id], onDelete: SetNull)
requestedBy User? @relation("MentorChangeRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
resolvedBy User? @relation("MentorChangeResolver", fields: [resolvedByUserId], references: [id])
@@index([projectId])
@@index([status])
@@index([targetAssignmentId])
}
// ============================================================================= // =============================================================================
// FILTERING ROUND SYSTEM // FILTERING ROUND SYSTEM
// ============================================================================= // =============================================================================
@@ -2449,7 +2498,8 @@ model AssignmentIntent {
model MentorFile { model MentorFile {
id String @id @default(cuid()) id String @id @default(cuid())
mentorAssignmentId String projectId String // Primary access scope: files belong to the team
mentorAssignmentId String? // Nullable audit FK: which assignment uploaded; survives mentor drop
uploadedByUserId String uploadedByUserId String
fileName String fileName String
@@ -2468,13 +2518,15 @@ model MentorFile {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
// Relations // Relations
mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
mentorAssignment MentorAssignment? @relation(fields: [mentorAssignmentId], references: [id], onDelete: SetNull)
uploadedBy User @relation("MentorFileUploader", fields: [uploadedByUserId], references: [id]) uploadedBy User @relation("MentorFileUploader", fields: [uploadedByUserId], references: [id])
promotedBy User? @relation("MentorFilePromoter", fields: [promotedByUserId], references: [id]) promotedBy User? @relation("MentorFilePromoter", fields: [promotedByUserId], references: [id])
promotedFile ProjectFile? @relation("PromotedFromMentorFile", fields: [promotedToFileId], references: [id], onDelete: SetNull) promotedFile ProjectFile? @relation("PromotedFromMentorFile", fields: [promotedToFileId], references: [id], onDelete: SetNull)
comments MentorFileComment[] comments MentorFileComment[]
promotionEvents SubmissionPromotionEvent[] promotionEvents SubmissionPromotionEvent[]
@@index([projectId])
@@index([mentorAssignmentId]) @@index([mentorAssignmentId])
@@index([uploadedByUserId]) @@index([uploadedByUserId])
} }

View File

@@ -335,20 +335,20 @@ function RoundsDndGrid({
function ConfidenceBadge({ confidence }: { confidence: number }) { function ConfidenceBadge({ confidence }: { confidence: number }) {
if (confidence > 0.8) { if (confidence > 0.8) {
return ( return (
<Badge variant="outline" className="border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs tabular-nums"> <Badge variant="outline" className="border-emerald-300 bg-emerald-50 text-emerald-700 text-xs tabular-nums">
{Math.round(confidence * 100)}% {Math.round(confidence * 100)}%
</Badge> </Badge>
) )
} }
if (confidence >= 0.5) { if (confidence >= 0.5) {
return ( return (
<Badge variant="outline" className="border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-400 text-xs tabular-nums"> <Badge variant="outline" className="border-amber-300 bg-amber-50 text-amber-700 text-xs tabular-nums">
{Math.round(confidence * 100)}% {Math.round(confidence * 100)}%
</Badge> </Badge>
) )
} }
return ( return (
<Badge variant="outline" className="border-red-300 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-950/30 dark:text-red-400 text-xs tabular-nums"> <Badge variant="outline" className="border-red-300 bg-red-50 text-red-700 text-xs tabular-nums">
{Math.round(confidence * 100)}% {Math.round(confidence * 100)}%
</Badge> </Badge>
) )
@@ -897,8 +897,8 @@ export default function AwardDetailPage({
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Eligible</p> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Eligible</p>
<p className="text-2xl font-bold tabular-nums">{award.eligibleCount}</p> <p className="text-2xl font-bold tabular-nums">{award.eligibleCount}</p>
</div> </div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/40"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100">
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400" /> <CheckCircle2 className="h-5 w-5 text-emerald-600" />
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -910,8 +910,8 @@ export default function AwardDetailPage({
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Evaluated</p> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Evaluated</p>
<p className="text-2xl font-bold tabular-nums">{(award as any).totalAssessed ?? award._count.eligibilities}</p> <p className="text-2xl font-bold tabular-nums">{(award as any).totalAssessed ?? award._count.eligibilities}</p>
</div> </div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950/40"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100">
<ListChecks className="h-5 w-5 text-blue-600 dark:text-blue-400" /> <ListChecks className="h-5 w-5 text-blue-600" />
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -923,8 +923,8 @@ export default function AwardDetailPage({
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Jurors</p> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Jurors</p>
<p className="text-2xl font-bold tabular-nums">{award._count.jurors}</p> <p className="text-2xl font-bold tabular-nums">{award._count.jurors}</p>
</div> </div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-violet-100 dark:bg-violet-950/40"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-violet-100">
<Users className="h-5 w-5 text-violet-600 dark:text-violet-400" /> <Users className="h-5 w-5 text-violet-600" />
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -936,8 +936,8 @@ export default function AwardDetailPage({
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Votes</p> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Votes</p>
<p className="text-2xl font-bold tabular-nums">{award._count.votes}</p> <p className="text-2xl font-bold tabular-nums">{award._count.votes}</p>
</div> </div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-950/40"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100">
<Vote className="h-5 w-5 text-amber-600 dark:text-amber-400" /> <Vote className="h-5 w-5 text-amber-600" />
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -1612,7 +1612,7 @@ export default function AwardDetailPage({
{/* Rounds Tab */} {/* Rounds Tab */}
<TabsContent value="rounds" className="space-y-4"> <TabsContent value="rounds" className="space-y-4">
{award.eligibilityMode !== 'SEPARATE_POOL' && ( {award.eligibilityMode !== 'SEPARATE_POOL' && (
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-blue-800 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300"> <div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-blue-800">
<Info className="h-4 w-4 mt-0.5 shrink-0" /> <Info className="h-4 w-4 mt-0.5 shrink-0" />
<p className="text-sm"> <p className="text-sm">
Rounds are used in <strong>Separate Pool</strong> mode to create a dedicated evaluation track for shortlisted projects. Rounds are used in <strong>Separate Pool</strong> mode to create a dedicated evaluation track for shortlisted projects.
@@ -1620,7 +1620,7 @@ export default function AwardDetailPage({
</div> </div>
)} )}
{!award.competitionId && ( {!award.competitionId && (
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300"> <div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" /> <AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
<p className="text-sm"> <p className="text-sm">
Link this award to a competition first before creating rounds. Link this award to a competition first before creating rounds.
@@ -1750,16 +1750,16 @@ export default function AwardDetailPage({
return ( return (
<TableRow <TableRow
key={r.project.id} key={r.project.id}
className={isWinner ? 'bg-amber-50/80 dark:bg-amber-950/20' : ''} className={isWinner ? 'bg-amber-50/80' : ''}
> >
<TableCell> <TableCell>
<span className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-xs font-bold ${ <span className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-xs font-bold ${
i === 0 i === 0
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300' ? 'bg-amber-100 text-amber-800'
: i === 1 : i === 1
? 'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-300' ? 'bg-slate-200 text-slate-700'
: i === 2 : i === 2
? 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300' ? 'bg-orange-100 text-orange-800'
: 'text-muted-foreground' : 'text-muted-foreground'
}`}> }`}>
{i + 1} {i + 1}

View File

@@ -976,17 +976,39 @@ export default function MemberDetailPage() {
Cancel Cancel
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={() => { onClick={async () => {
if (!pendingAdditionalRole) return if (!pendingAdditionalRole) return
const { role: r, action } = pendingAdditionalRole const { role: r, action } = pendingAdditionalRole
if (action === 'add') { const nextAdditional =
setAdditionalRoles((prev) => action === 'add'
prev.includes(r) ? prev : [...prev, r] ? additionalRoles.includes(r)
? additionalRoles
: [...additionalRoles, r]
: additionalRoles.filter((x) => x !== r)
const nextAllRoles = [
role,
...nextAdditional.filter((x) => x !== role),
] as Array<'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'APPLICANT' | 'AUDIENCE'>
try {
await updateUser.mutateAsync({
id: userId,
roles: nextAllRoles,
})
setAdditionalRoles(nextAdditional)
utils.user.get.invalidate({ id: userId })
utils.user.list.invalidate()
toast.success(
action === 'add'
? `${r.replace(/_/g, ' ')} role added`
: `${r.replace(/_/g, ' ')} role removed`,
) )
} else { } catch (error) {
setAdditionalRoles((prev) => prev.filter((x) => x !== r)) toast.error(
} error instanceof Error ? error.message : 'Failed to update roles',
)
} finally {
setPendingAdditionalRole(null) setPendingAdditionalRole(null)
}
}} }}
> >
{pendingAdditionalRole?.action === 'add' ? 'Add role' : 'Remove role'} {pendingAdditionalRole?.action === 'add' ? 'Add role' : 'Remove role'}
@@ -1047,7 +1069,7 @@ export default function MemberDetailPage() {
/> />
</div> </div>
<div className="flex items-center justify-between gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-100"> <div className="flex items-center justify-between gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Clock className="h-3.5 w-3.5 shrink-0" /> <Clock className="h-3.5 w-3.5 shrink-0" />
<span> <span>

View File

@@ -907,7 +907,7 @@ export default function MemberInvitePage() {
</div> </div>
{!sendInvitation && ( {!sendInvitation && (
<div className="flex items-start gap-3 rounded-lg bg-blue-500/10 p-4 text-blue-700 dark:text-blue-400"> <div className="flex items-start gap-3 rounded-lg bg-blue-500/10 p-4 text-blue-700">
<MailX className="h-5 w-5 shrink-0 mt-0.5" /> <MailX className="h-5 w-5 shrink-0 mt-0.5" />
<div> <div>
<p className="font-medium">No invitations will be sent</p> <p className="font-medium">No invitations will be sent</p>

View File

@@ -15,9 +15,12 @@ import {
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Checkbox } from '@/components/ui/checkbox'
import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { import {
Table, Table,
@@ -27,15 +30,35 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { import {
AlertTriangle, AlertTriangle,
ArrowLeft, ArrowLeft,
Bot, Bot,
Check, Check,
Inbox,
Loader2, Loader2,
Search, Search,
Sparkles, Sparkles,
Users, Users,
UserPlus,
} from 'lucide-react' } from 'lucide-react'
import { getInitials, formatEnumLabel } from '@/lib/utils' import { getInitials, formatEnumLabel } from '@/lib/utils'
@@ -48,14 +71,34 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
const utils = trpc.useUtils() const utils = trpc.useUtils()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [pendingMentorId, setPendingMentorId] = useState<string | null>(null) const [pendingMentorId, setPendingMentorId] = useState<string | null>(null)
const [unassignTarget, setUnassignTarget] = useState<{
assignmentId: string
mentorName: string
} | null>(null)
const [selectedCandidateIds, setSelectedCandidateIds] = useState<Set<string>>(
new Set(),
)
const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({ id: projectId }) const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({ id: projectId })
const { data: candidatesData, isLoading: candidatesLoading } = // Already-assigned mentors (full list). Project.get spreads the underlying
trpc.mentor.getCandidates.useQuery( // `mentorAssignments` relation so we can read it directly.
{ projectId }, const assignedMentorAssignments = useMemo(() => {
{ enabled: !!project && !project.mentorAssignment }, if (!project) return []
// The Prisma relation is included via `...project` spread; type comes
// through the tRPC client.
type Assignment = NonNullable<typeof project>['mentorAssignments'][number]
return ((project as unknown as { mentorAssignments?: Assignment[] }).mentorAssignments ?? []).filter(
(a) => !a.droppedAt,
) )
}, [project])
const assignedMentorIds = useMemo(
() => new Set(assignedMentorAssignments.map((a) => a.mentorId)),
[assignedMentorAssignments],
)
const { data: candidatesData, isLoading: candidatesLoading } =
trpc.mentor.getCandidates.useQuery({ projectId }, { enabled: !!project })
const { const {
data: suggestionsData, data: suggestionsData,
@@ -63,15 +106,16 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
refetch: refetchSuggestions, refetch: refetchSuggestions,
} = trpc.mentor.getSuggestions.useQuery( } = trpc.mentor.getSuggestions.useQuery(
{ projectId, limit: 5 }, { projectId, limit: 5 },
{ enabled: !!project && !project.mentorAssignment }, { enabled: !!project },
) )
const assignMutation = trpc.mentor.assign.useMutation({ const assignMutation = trpc.mentor.assign.useMutation({
onSuccess: () => { onSuccess: () => {
toast.success('Mentor assigned') toast.success('Mentor added')
utils.project.get.invalidate({ id: projectId }) utils.project.get.invalidate({ id: projectId })
utils.mentor.getCandidates.invalidate({ projectId }) utils.mentor.getCandidates.invalidate({ projectId })
utils.mentor.getSuggestions.invalidate({ projectId }) utils.mentor.getSuggestions.invalidate({ projectId })
utils.mentor.getMentorPool.invalidate()
setPendingMentorId(null) setPendingMentorId(null)
}, },
onError: (err) => { onError: (err) => {
@@ -80,27 +124,61 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
}, },
}) })
const bulkAssignMutation = trpc.mentor.bulkAssign.useMutation({
onSuccess: (result) => {
if (result.totalAssigned === 0) {
toast.info('No new assignments — every chosen mentor was already on this team.')
} else {
toast.success(
`Added ${result.totalAssigned} mentor${
result.totalAssigned === 1 ? '' : 's'
} to this team${
result.emailsSent > 0
? ` · ${result.emailsSent} email${result.emailsSent === 1 ? '' : 's'} sent`
: ' · emails will go out when the mentoring round opens'
}`,
)
}
utils.project.get.invalidate({ id: projectId })
utils.mentor.getCandidates.invalidate({ projectId })
utils.mentor.getSuggestions.invalidate({ projectId })
utils.mentor.getMentorPool.invalidate()
setSelectedCandidateIds(new Set())
},
onError: (err) => toast.error(err.message),
})
const unassignMutation = trpc.mentor.unassign.useMutation({ const unassignMutation = trpc.mentor.unassign.useMutation({
onSuccess: () => { onSuccess: () => {
toast.success('Mentor removed') toast.success('Mentor removed')
utils.project.get.invalidate({ id: projectId }) utils.project.get.invalidate({ id: projectId })
utils.mentor.getCandidates.invalidate({ projectId }) utils.mentor.getCandidates.invalidate({ projectId })
utils.mentor.getSuggestions.invalidate({ projectId }) utils.mentor.getSuggestions.invalidate({ projectId })
setUnassignTarget(null)
},
onError: (err) => {
toast.error(err.message)
setUnassignTarget(null)
}, },
onError: (err) => toast.error(err.message),
}) })
const filteredCandidates = useMemo(() => { const filteredCandidates = useMemo(() => {
if (!candidatesData) return [] if (!candidatesData) return []
const base = candidatesData.candidates.filter((c) => !assignedMentorIds.has(c.id))
const q = search.trim().toLowerCase() const q = search.trim().toLowerCase()
if (!q) return candidatesData.candidates if (!q) return base
return candidatesData.candidates.filter((c) => { return base.filter((c) => {
const hay = [c.name ?? '', c.email, ...(c.expertiseTags ?? []), c.country ?? ''] const hay = [c.name ?? '', c.email, ...(c.expertiseTags ?? []), c.country ?? '']
.join(' ') .join(' ')
.toLowerCase() .toLowerCase()
return hay.includes(q) return hay.includes(q)
}) })
}, [candidatesData, search]) }, [candidatesData, search, assignedMentorIds])
const filteredSuggestions = useMemo(() => {
if (!suggestionsData) return []
return suggestionsData.suggestions.filter((s) => !assignedMentorIds.has(s.mentorId))
}, [suggestionsData, assignedMentorIds])
if (projectLoading) return <MentorAssignmentSkeleton /> if (projectLoading) return <MentorAssignmentSkeleton />
if (!project) { if (!project) {
@@ -113,7 +191,6 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
) )
} }
const hasMentor = !!project.mentorAssignment
const teamSize = project.teamMembers?.length ?? 0 const teamSize = project.teamMembers?.length ?? 0
const aiSource = suggestionsData?.source ?? 'ai' const aiSource = suggestionsData?.source ?? 'ai'
@@ -206,80 +283,112 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
</CardContent> </CardContent>
</Card> </Card>
{/* ─── Pending Change Requests ─── */}
<PendingChangeRequestsPanel projectId={projectId} />
{/* ─── Currently Assigned ─── */} {/* ─── Currently Assigned ─── */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Currently Assigned</CardTitle> <CardTitle className="text-lg">Currently Assigned</CardTitle>
<CardDescription>
{assignedMentorAssignments.length === 0
? 'No mentors assigned yet'
: `${assignedMentorAssignments.length} mentor${
assignedMentorAssignments.length === 1 ? '' : 's'
} on this team`}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{hasMentor ? ( {assignedMentorAssignments.length === 0 ? (
<div className="flex items-center justify-between"> <div className="rounded-md border border-dashed py-8 text-center">
<div className="flex items-center gap-4"> <Users className="text-muted-foreground mx-auto mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm">
No mentors assigned yet add one below.
</p>
</div>
) : (
<ul className="divide-y">
{assignedMentorAssignments.map((a) => {
const m = a.mentor
const tags = m.expertiseTags ?? []
return (
<li
key={a.id}
className="flex items-start justify-between gap-4 py-4 first:pt-0 last:pb-0"
>
<div className="flex flex-1 items-start gap-4">
<Avatar className="h-12 w-12"> <Avatar className="h-12 w-12">
<AvatarFallback> <AvatarFallback>
{getInitials( {getInitials(m.name || m.email)}
project.mentorAssignment!.mentor.name ||
project.mentorAssignment!.mentor.email,
)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div> <div className="min-w-0 flex-1">
<Link <Link
href={`/admin/mentors/${project.mentorAssignment!.mentor.id}`} href={`/admin/mentors/${m.id}`}
className="font-medium hover:underline" className="font-medium hover:underline"
> >
{project.mentorAssignment!.mentor.name || 'Unnamed'} {m.name || 'Unnamed'}
</Link> </Link>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">{m.email}</p>
{project.mentorAssignment!.mentor.email} {tags.length > 0 && (
</p>
{project.mentorAssignment!.mentor.expertiseTags &&
project.mentorAssignment!.mentor.expertiseTags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-1 flex flex-wrap gap-1">
{project.mentorAssignment!.mentor.expertiseTags {tags.slice(0, 5).map((tag: string) => (
.slice(0, 5)
.map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs"> <Badge key={tag} variant="secondary" className="text-xs">
{tag} {tag}
</Badge> </Badge>
))} ))}
{tags.length > 5 && (
<Badge variant="outline" className="text-xs">
+{tags.length - 5}
</Badge>
)}
</div> </div>
)} )}
<p className="text-muted-foreground mt-2 text-xs">
Assigned{' '}
{new Date(a.assignedAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</p>
</div> </div>
</div> </div>
<div className="flex flex-col items-end gap-2"> <div className="flex flex-col items-end gap-2">
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{project.mentorAssignment!.method.replace(/_/g, ' ')} {a.method.replace(/_/g, ' ')}
</Badge> </Badge>
<Button <Button
variant="destructive" variant="outline"
size="sm" size="sm"
onClick={() => unassignMutation.mutate({ projectId })} onClick={() =>
setUnassignTarget({
assignmentId: a.id,
mentorName: m.name || m.email,
})
}
disabled={unassignMutation.isPending} disabled={unassignMutation.isPending}
> >
{unassignMutation.isPending ? ( Unassign
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Unassign'
)}
</Button> </Button>
</div> </div>
</div> </li>
) : ( )
<p className="text-muted-foreground text-sm"> })}
No mentor assigned yet pick one below. </ul>
</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* ─── Pick a Mentor ─── */} {/* ─── Add a Mentor ─── */}
{!hasMentor && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Pick a Mentor</CardTitle> <CardTitle className="text-lg flex items-center gap-2">
<UserPlus className="h-5 w-5" />
Add a Mentor
</CardTitle>
<CardDescription> <CardDescription>
Browse all eligible mentors or use AI to surface the best fits. Stack additional mentors on this team. Browse all eligible mentors or use AI to surface the best fits.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -303,6 +412,41 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
className="pl-9" className="pl-9"
/> />
</div> </div>
{selectedCandidateIds.size > 0 && (
<div className="flex flex-col gap-2 rounded-md border border-primary/30 bg-primary/5 px-4 py-2.5 text-sm sm:flex-row sm:items-center sm:justify-between">
<div>
<span className="font-medium">{selectedCandidateIds.size}</span>{' '}
<span className="text-muted-foreground">
mentor{selectedCandidateIds.size === 1 ? '' : 's'} selected
</span>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
onClick={() =>
bulkAssignMutation.mutate({
mentorIds: Array.from(selectedCandidateIds),
projectIds: [projectId],
})
}
disabled={bulkAssignMutation.isPending}
>
{bulkAssignMutation.isPending && (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
)}
Add {selectedCandidateIds.size} mentor
{selectedCandidateIds.size === 1 ? '' : 's'}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setSelectedCandidateIds(new Set())}
>
Clear
</Button>
</div>
</div>
)}
{candidatesLoading ? ( {candidatesLoading ? (
<div className="space-y-2"> <div className="space-y-2">
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
@@ -311,13 +455,37 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
</div> </div>
) : filteredCandidates.length === 0 ? ( ) : filteredCandidates.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm"> <div className="text-muted-foreground py-8 text-center text-sm">
No matching mentors. Try a different search. {assignedMentorIds.size > 0 && search.trim() === ''
? 'All eligible mentors are already assigned.'
: 'No matching mentors. Try a different search.'}
</div> </div>
) : ( ) : (
<div className="overflow-hidden rounded-md border"> <div className="overflow-hidden rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-10">
<Checkbox
checked={
filteredCandidates.length > 0 &&
filteredCandidates.every((c) =>
selectedCandidateIds.has(c.id),
)
}
onCheckedChange={(checked) => {
setSelectedCandidateIds((prev) => {
const next = new Set(prev)
if (checked) {
filteredCandidates.forEach((c) => next.add(c.id))
} else {
filteredCandidates.forEach((c) => next.delete(c.id))
}
return next
})
}}
aria-label="Select all visible mentors"
/>
</TableHead>
<TableHead>Mentor</TableHead> <TableHead>Mentor</TableHead>
<TableHead>Expertise</TableHead> <TableHead>Expertise</TableHead>
<TableHead>Country</TableHead> <TableHead>Country</TableHead>
@@ -328,7 +496,26 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredCandidates.map((c) => ( {filteredCandidates.map((c) => (
<TableRow key={c.id}> <TableRow
key={c.id}
data-state={
selectedCandidateIds.has(c.id) ? 'selected' : undefined
}
>
<TableCell>
<Checkbox
checked={selectedCandidateIds.has(c.id)}
onCheckedChange={(checked) =>
setSelectedCandidateIds((prev) => {
const next = new Set(prev)
if (checked) next.add(c.id)
else next.delete(c.id)
return next
})
}
aria-label={`Select ${c.name ?? c.email}`}
/>
</TableCell>
<TableCell> <TableCell>
<div className="font-medium">{c.name ?? 'Unnamed'}</div> <div className="font-medium">{c.name ?? 'Unnamed'}</div>
<div className="text-muted-foreground text-xs">{c.email}</div> <div className="text-muted-foreground text-xs">{c.email}</div>
@@ -376,7 +563,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
<> <>
<Check className="mr-1 h-3.5 w-3.5" /> Assign <Check className="mr-1 h-3.5 w-3.5" /> Add
</> </>
)} )}
</Button> </Button>
@@ -391,7 +578,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
<TabsContent value="ai" className="space-y-4"> <TabsContent value="ai" className="space-y-4">
{aiSource === 'fallback' && ( {aiSource === 'fallback' && (
<div className="flex items-start gap-3 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm dark:border-amber-700 dark:bg-amber-950/40"> <div className="flex items-start gap-3 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" /> <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
<div> <div>
<p className="font-medium">AI matching unavailable</p> <p className="font-medium">AI matching unavailable</p>
@@ -422,13 +609,15 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
<Skeleton key={i} className="h-24 w-full" /> <Skeleton key={i} className="h-24 w-full" />
))} ))}
</div> </div>
) : !suggestionsData || suggestionsData.suggestions.length === 0 ? ( ) : filteredSuggestions.length === 0 ? (
<p className="text-muted-foreground py-8 text-center text-sm"> <p className="text-muted-foreground py-8 text-center text-sm">
No suggestions available. {assignedMentorIds.size > 0
? 'All top suggestions are already assigned.'
: 'No suggestions available.'}
</p> </p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{suggestionsData.suggestions.map((s, i) => ( {filteredSuggestions.map((s, i) => (
<div <div
key={s.mentorId} key={s.mentorId}
className="flex items-start justify-between rounded-md border p-4" className="flex items-start justify-between rounded-md border p-4"
@@ -503,7 +692,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
<> <>
<Check className="mr-1 h-3.5 w-3.5" /> Assign <Check className="mr-1 h-3.5 w-3.5" /> Add
</> </>
)} )}
</Button> </Button>
@@ -515,8 +704,284 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
</Tabs> </Tabs>
</CardContent> </CardContent>
</Card> </Card>
{/* ─── Unassign confirm ─── */}
<AlertDialog
open={!!unassignTarget}
onOpenChange={(open) => {
if (!open) setUnassignTarget(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unassign mentor?</AlertDialogTitle>
<AlertDialogDescription>
{unassignTarget
? `Remove ${unassignTarget.mentorName} from this team? Other co-mentors will remain.`
: ''}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={unassignMutation.isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault()
if (!unassignTarget) return
unassignMutation.mutate({ assignmentId: unassignTarget.assignmentId })
}}
disabled={unassignMutation.isPending}
>
{unassignMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Unassign'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// Pending Change Requests panel
// ─────────────────────────────────────────────────────────────────────────────
function PendingChangeRequestsPanel({ projectId }: { projectId: string }) {
const utils = trpc.useUtils()
const { data: requests, isLoading } = trpc.mentor.listChangeRequests.useQuery({
projectId,
status: 'PENDING',
})
const [resolveTarget, setResolveTarget] = useState<{
id: string
status: 'RESOLVED' | 'DISMISSED'
requesterName: string
} | null>(null)
const [resolutionNote, setResolutionNote] = useState('')
const resolveMutation = trpc.mentor.resolveChangeRequest.useMutation({
onSuccess: (_, variables) => {
toast.success(
`Request marked ${variables.status === 'RESOLVED' ? 'resolved' : 'dismissed'}`,
)
utils.mentor.listChangeRequests.invalidate()
setResolveTarget(null)
setResolutionNote('')
},
onError: (err) => toast.error(err.message),
})
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Inbox className="h-5 w-5" />
Pending change requests
</CardTitle>
</CardHeader>
<CardContent>
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
)
}
if (!requests || requests.length === 0) {
return null
}
return (
<>
<Card className="border-amber-300">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Inbox className="h-5 w-5 text-amber-600" />
Pending change requests
<Badge variant="secondary" className="ml-1">
{requests.length}
</Badge>
</CardTitle>
<CardDescription>
Team members or mentors have asked admin to change a mentor on this team.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{requests.map((r) => (
<ChangeRequestRow
key={r.id}
request={r}
onResolve={(status) =>
setResolveTarget({
id: r.id,
status,
requesterName:
r.requestedBy?.name ?? r.requestedBy?.email ?? 'Unknown',
})
}
/>
))}
</ul>
</CardContent>
</Card>
<Dialog
open={!!resolveTarget}
onOpenChange={(open) => {
if (!open) {
setResolveTarget(null)
setResolutionNote('')
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{resolveTarget?.status === 'RESOLVED'
? 'Mark request resolved'
: 'Dismiss request'}
</DialogTitle>
<DialogDescription>
{resolveTarget?.status === 'RESOLVED'
? `You've taken action on the request from ${resolveTarget?.requesterName}. Optionally add a note explaining what was done.`
: `Close the request from ${resolveTarget?.requesterName} without action. Optionally add a note explaining why.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="resolution-note">Resolution note (optional)</Label>
<Textarea
id="resolution-note"
value={resolutionNote}
onChange={(e) => setResolutionNote(e.target.value)}
placeholder="e.g. Replaced Jane with John based on expertise mismatch."
rows={4}
maxLength={2000}
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setResolveTarget(null)
setResolutionNote('')
}}
disabled={resolveMutation.isPending}
>
Cancel
</Button>
<Button
onClick={() => {
if (!resolveTarget) return
resolveMutation.mutate({
id: resolveTarget.id,
status: resolveTarget.status,
resolutionNote: resolutionNote.trim() || undefined,
})
}}
disabled={resolveMutation.isPending}
>
{resolveMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : resolveTarget?.status === 'RESOLVED' ? (
'Mark Resolved'
) : (
'Dismiss'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
type ChangeRequestRowProps = {
request: {
id: string
reason: string
createdAt: Date
requestedBy: { id: string; name: string | null; email: string } | null
targetAssignment: {
id: string
mentor: { id: string; name: string | null; email: string }
} | null
}
onResolve: (status: 'RESOLVED' | 'DISMISSED') => void
}
function ChangeRequestRow({ request, onResolve }: ChangeRequestRowProps) {
const [expanded, setExpanded] = useState(false)
const reasonIsLong = request.reason.length > 240
return (
<li className="rounded-md border bg-card p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
<span className="font-medium">
{request.requestedBy?.name ?? request.requestedBy?.email ?? 'Unknown'}
</span>
{request.requestedBy?.email && request.requestedBy.name && (
<span className="text-muted-foreground text-xs">
{request.requestedBy.email}
</span>
)}
<span className="text-muted-foreground text-xs">
·{' '}
{new Date(request.createdAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
</div>
{request.targetAssignment && (
<div className="text-muted-foreground text-xs">
About:{' '}
<span className="font-medium">
{request.targetAssignment.mentor.name ||
request.targetAssignment.mentor.email}
</span>
</div>
)}
<p
className={
expanded || !reasonIsLong
? 'text-sm whitespace-pre-wrap'
: 'text-sm whitespace-pre-wrap line-clamp-4'
}
>
{request.reason}
</p>
{reasonIsLong && (
<button
type="button"
className="text-primary text-xs hover:underline"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? 'Show less' : 'Show more'}
</button>
)} )}
</div> </div>
<div className="flex shrink-0 flex-col gap-2">
<Button size="sm" onClick={() => onResolve('RESOLVED')}>
Mark Resolved
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onResolve('DISMISSED')}
>
Dismiss
</Button>
</div>
</div>
</li>
) )
} }

View File

@@ -462,7 +462,7 @@ export default function BulkUploadPage() {
return ( return (
<TableRow <TableRow
key={row.project.id} key={row.project.id}
className={row.isComplete ? 'bg-green-50/50 dark:bg-green-950/10' : ''} className={row.isComplete ? 'bg-green-50/50' : ''}
> >
<TableCell> <TableCell>
<Link <Link

View File

@@ -53,15 +53,15 @@ type TeamMemberEntry = {
} }
const ROLE_COLORS: Record<string, string> = { const ROLE_COLORS: Record<string, string> = {
LEAD: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', LEAD: 'bg-red-100 text-red-700',
MEMBER: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400', MEMBER: 'bg-teal-100 text-teal-700',
ADVISOR: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', ADVISOR: 'bg-blue-100 text-blue-700',
} }
const ROLE_AVATAR_COLORS: Record<string, string> = { const ROLE_AVATAR_COLORS: Record<string, string> = {
LEAD: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', LEAD: 'bg-red-100 text-red-700',
MEMBER: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300', MEMBER: 'bg-teal-100 text-teal-700',
ADVISOR: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', ADVISOR: 'bg-blue-100 text-blue-700',
} }
const ROLE_LABELS: Record<string, string> = { const ROLE_LABELS: Record<string, string> = {

View File

@@ -679,7 +679,7 @@ export default function ProjectsPage() {
<Button <Button
variant="outline" variant="outline"
onClick={() => setAiTagDialogOpen(true)} onClick={() => setAiTagDialogOpen(true)}
className={taggingInProgress ? 'border-amber-400 bg-amber-50 dark:bg-amber-950/20' : ''} className={taggingInProgress ? 'border-amber-400 bg-amber-50' : ''}
> >
{taggingInProgress ? ( {taggingInProgress ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin text-amber-600" /> <Loader2 className="mr-2 h-4 w-4 animate-spin text-amber-600" />
@@ -1716,15 +1716,15 @@ export default function ProjectsPage() {
<div className="space-y-6 py-4"> <div className="space-y-6 py-4">
{/* Progress Indicator (when running) */} {/* Progress Indicator (when running) */}
{taggingInProgress && ( {taggingInProgress && (
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900"> <div className="p-4 rounded-lg bg-blue-50 border border-blue-200">
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-blue-600" /> <Loader2 className="h-5 w-5 animate-spin text-blue-600" />
<div className="flex-1"> <div className="flex-1">
<p className="font-medium text-blue-900 dark:text-blue-100"> <p className="font-medium text-blue-900">
AI Tagging in Progress AI Tagging in Progress
</p> </p>
<p className="text-sm text-blue-700 dark:text-blue-300"> <p className="text-sm text-blue-700">
{jobStatus?.status === 'PENDING' {jobStatus?.status === 'PENDING'
? 'Initializing...' ? 'Initializing...'
: `Processing ${jobStatus?.totalProjects || 0} projects with AI...`} : `Processing ${jobStatus?.totalProjects || 0} projects with AI...`}
@@ -1739,12 +1739,12 @@ export default function ProjectsPage() {
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-blue-700 dark:text-blue-300"> <span className="text-blue-700">
{jobStatus?.processedCount || 0} of {jobStatus?.totalProjects || '?'} projects processed {jobStatus?.processedCount || 0} of {jobStatus?.totalProjects || '?'} projects processed
{jobStatus?.taggedCount ? ` (${jobStatus.taggedCount} tagged)` : ''} {jobStatus?.taggedCount ? ` (${jobStatus.taggedCount} tagged)` : ''}
</span> </span>
{jobStatus && jobStatus.totalProjects > 0 && ( {jobStatus && jobStatus.totalProjects > 0 && (
<span className="font-medium text-blue-900 dark:text-blue-100"> <span className="font-medium text-blue-900">
{taggingProgressPercent}% {taggingProgressPercent}%
</span> </span>
)} )}
@@ -1767,9 +1767,9 @@ export default function ProjectsPage() {
{taggingResult && !taggingInProgress && ( {taggingResult && !taggingInProgress && (
<div className={`p-4 rounded-lg border ${ <div className={`p-4 rounded-lg border ${
taggingResult.failed > 0 taggingResult.failed > 0
? 'bg-amber-50 dark:bg-amber-950/20 border-amber-200 dark:border-amber-900' ? 'bg-amber-50 border-amber-200'
: taggingResult.processed > 0 : taggingResult.processed > 0
? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900' ? 'bg-green-50 border-green-200'
: 'bg-muted border-border' : 'bg-muted border-border'
}`}> }`}>
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
@@ -1804,12 +1804,12 @@ export default function ProjectsPage() {
</div> </div>
{taggingResult.errors.length > 0 && ( {taggingResult.errors.length > 0 && (
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
<p className="text-sm font-medium text-amber-700 dark:text-amber-300"> <p className="text-sm font-medium text-amber-700">
{taggingResult.errors.length} project{taggingResult.errors.length > 1 ? 's' : ''} failed: {taggingResult.errors.length} project{taggingResult.errors.length > 1 ? 's' : ''} failed:
</p> </p>
<div className="max-h-32 overflow-y-auto rounded bg-background/50 p-2 text-xs space-y-1"> <div className="max-h-32 overflow-y-auto rounded bg-background/50 p-2 text-xs space-y-1">
{taggingResult.errors.map((error, i) => ( {taggingResult.errors.map((error, i) => (
<p key={i} className="text-amber-700 dark:text-amber-300"> <p key={i} className="text-amber-700">
{error} {error}
</p> </p>
))} ))}

View File

@@ -52,6 +52,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { formatDateOnly } from '@/lib/utils' import { formatDateOnly } from '@/lib/utils'
import { getCountryName, getCountryFlag } from '@/lib/countries'
import { import {
ScoreDistributionChart, ScoreDistributionChart,
EvaluationTimelineChart, EvaluationTimelineChart,
@@ -91,6 +92,17 @@ function ReportsOverview({ scope }: { scope: string | null }) {
{ enabled: hasScope } { enabled: hasScope }
) )
// Applicant nationality breakdown — always runs (scope optional;
// empty scope = global view across all programs).
const { data: nationalityStats, isLoading: nationalityLoading } =
trpc.analytics.getApplicantNationalities.useQuery(scopeInput)
const nationalityScopeLabel = scopeInput.roundId
? 'in this round'
: scopeInput.programId
? `in ${programs?.find((p) => p.id === scopeInput.programId)?.year ?? ''} Edition`
: 'across all programs'
if (isLoading || statsLoading) { if (isLoading || statsLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -198,6 +210,13 @@ function ReportsOverview({ scope }: { scope: string | null }) {
</AnimatedCard> </AnimatedCard>
</div> </div>
{/* Applicant Nationalities */}
<ApplicantNationalitiesCard
data={nationalityStats}
loading={nationalityLoading}
scopeLabel={nationalityScopeLabel}
/>
{/* Score Distribution (if any evaluations exist) */} {/* Score Distribution (if any evaluations exist) */}
{dashStats?.scoreDistribution && dashStats.scoreDistribution.some(b => b.count > 0) && ( {dashStats?.scoreDistribution && dashStats.scoreDistribution.some(b => b.count > 0) && (
<Card> <Card>
@@ -500,6 +519,140 @@ function ReportsOverview({ scope }: { scope: string | null }) {
) )
} }
type NationalityStats = {
total: number
declared: number
notDeclared: number
byCountry: Array<{ country: string; count: number }>
}
function ApplicantNationalitiesCard({
data,
loading,
scopeLabel,
}: {
data: NationalityStats | undefined
loading: boolean
scopeLabel: string
}) {
const [showAll, setShowAll] = useState(false)
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Globe className="h-4 w-4 text-violet-600" />
</div>
Applicant Nationalities
</CardTitle>
<CardDescription>
Self-declared nationality of team members on projects {scopeLabel}.
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-3">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
</div>
) : !data || data.total === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Globe className="h-10 w-10 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">
No applicants in this scope.
</p>
</div>
) : data.declared === 0 ? (
<>
<NationalitySummary declared={data.declared} notDeclared={data.notDeclared} />
<div className="mt-4 flex flex-col items-center justify-center rounded-lg border border-dashed py-8 text-center">
<Globe className="h-8 w-8 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">
No nationality data yet.
</p>
</div>
</>
) : (
<>
<NationalitySummary declared={data.declared} notDeclared={data.notDeclared} />
<div className="mt-4 rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Country</TableHead>
<TableHead className="text-right w-32">Applicants</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(showAll ? data.byCountry : data.byCountry.slice(0, 10)).map((row) => {
const name = getCountryName(row.country)
const flag = getCountryFlag(row.country)
return (
<TableRow key={row.country}>
<TableCell className="font-medium">
<span className="inline-flex items-center gap-2">
{flag && <span aria-hidden>{flag}</span>}
<span>{name}</span>
{name !== row.country && (
<span className="text-xs text-muted-foreground tabular-nums">
{row.country}
</span>
)}
</span>
</TableCell>
<TableCell className="text-right">
<Badge variant="secondary" className="tabular-nums">
{row.count}
</Badge>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
{data.byCountry.length > 10 && (
<div className="mt-3 flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => setShowAll((v) => !v)}
className="gap-1 text-muted-foreground"
>
{showAll
? 'Show top 10'
: `Show all (${data.byCountry.length} countries)`}
<ArrowRight className="h-3.5 w-3.5" />
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
)
}
function NationalitySummary({ declared, notDeclared }: { declared: number; notDeclared: number }) {
return (
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground">Declared</p>
<p className="text-2xl font-bold tabular-nums">{declared}</p>
</div>
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground">Not declared</p>
<p className="text-2xl font-bold tabular-nums text-muted-foreground">
{notDeclared}
</p>
</div>
</div>
)
}
// Parse selection value: "all:programId" for edition-wide, or roundId // Parse selection value: "all:programId" for edition-wide, or roundId
function parseSelection(value: string | null): { roundId?: string; programId?: string } { function parseSelection(value: string | null): { roundId?: string; programId?: string } {
if (!value) return {} if (!value) return {}

View File

@@ -394,7 +394,7 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
</Link> </Link>
</Button> </Button>
<div> <div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground"> <h1 className="text-2xl font-bold tracking-tight text-brand-blue">
{isReadOnly ? 'Submitted Evaluation' : 'Fill In Evaluation'} {isReadOnly ? 'Submitted Evaluation' : 'Fill In Evaluation'}
</h1> </h1>
<div className="flex items-center gap-2 mt-1 flex-wrap"> <div className="flex items-center gap-2 mt-1 flex-wrap">
@@ -404,8 +404,8 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
variant="secondary" variant="secondary"
className={ className={
project.competitionCategory === 'STARTUP' project.competitionCategory === 'STARTUP'
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300' ? 'bg-violet-100 text-violet-700 border-violet-200'
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300' : 'bg-sky-100 text-sky-700 border-sky-200'
} }
> >
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'} {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
@@ -415,7 +415,7 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
</div> </div>
</div> </div>
<Card className="border-l-4 border-l-amber-500 bg-amber-50/40 dark:bg-amber-950/10"> <Card className="border-l-4 border-l-amber-500 bg-amber-50/40">
<CardContent className="flex items-start gap-3 p-4"> <CardContent className="flex items-start gap-3 p-4">
<UserCheck className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" /> <UserCheck className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
<div className="flex-1 text-sm"> <div className="flex-1 text-sm">
@@ -431,7 +431,7 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
</Card> </Card>
{isReadOnly && ( {isReadOnly && (
<Card className="border-l-4 border-l-blue-500 bg-blue-50/50 dark:bg-blue-950/20"> <Card className="border-l-4 border-l-blue-500 bg-blue-50/50">
<CardContent className="flex items-start gap-3 p-4"> <CardContent className="flex items-start gap-3 p-4">
<Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" /> <Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" />
<div className="flex-1"> <div className="flex-1">
@@ -446,7 +446,7 @@ export default function AdminProxyEvaluatePage({ params: paramsPromise }: PagePr
)} )}
{hasCOI && !isReadOnly && ( {hasCOI && !isReadOnly && (
<Card className="border-l-4 border-l-red-500 bg-red-50/40 dark:bg-red-950/10"> <Card className="border-l-4 border-l-red-500 bg-red-50/40">
<CardContent className="flex items-start gap-3 p-4"> <CardContent className="flex items-start gap-3 p-4">
<Lock className="h-5 w-5 text-red-600 shrink-0 mt-0.5" /> <Lock className="h-5 w-5 text-red-600 shrink-0 mt-0.5" />
<div className="flex-1 text-sm"> <div className="flex-1 text-sm">

View File

@@ -55,7 +55,7 @@ export default function AdminJurorProxyEvaluatePage({ params: paramsPromise }: P
</Link> </Link>
</Button> </Button>
<div> <div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground"> <h1 className="text-2xl font-bold tracking-tight text-brand-blue">
Proxy Evaluations Proxy Evaluations
</h1> </h1>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1">
@@ -205,8 +205,8 @@ function AssignmentRow({ roundId, userId, assignment, mode }: AssignmentRowProps
className={cn( className={cn(
'shrink-0', 'shrink-0',
project.competitionCategory === 'STARTUP' project.competitionCategory === 'STARTUP'
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300' ? 'bg-violet-100 text-violet-700 border-violet-200'
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300', : 'bg-sky-100 text-sky-700 border-sky-200',
)} )}
> >
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'} {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}

View File

@@ -92,6 +92,7 @@ import { ProjectStatesTable } from '@/components/admin/round/project-states-tabl
import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor' import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard' import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
import { MentoringRoundOverview } from '@/components/admin/round/mentoring-round-overview' import { MentoringRoundOverview } from '@/components/admin/round/mentoring-round-overview'
import { MentoringProjectsTable } from '@/components/admin/round/mentoring-projects-table'
import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card' import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card'
import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card' import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card'
import { RankingDashboard } from '@/components/admin/round/ranking-dashboard' import { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
@@ -124,6 +125,7 @@ import { ConfigSectionHeader } from '@/components/admin/rounds/config/config-sec
import { NotifyAdvancedButton } from '@/components/admin/round/notify-advanced-button' import { NotifyAdvancedButton } from '@/components/admin/round/notify-advanced-button'
import { NotifyRejectedButton } from '@/components/admin/round/notify-rejected-button' import { NotifyRejectedButton } from '@/components/admin/round/notify-rejected-button'
import { BulkInviteButton } from '@/components/admin/round/bulk-invite-button' import { BulkInviteButton } from '@/components/admin/round/bulk-invite-button'
import { SendMentorshipWelcomeButton } from '@/components/admin/round/send-mentorship-welcome-button'
import { AdvancementSummaryCard } from '@/components/admin/round/advancement-summary-card' import { AdvancementSummaryCard } from '@/components/admin/round/advancement-summary-card'
import { FinalizationTab } from '@/components/admin/round/finalization-tab' import { FinalizationTab } from '@/components/admin/round/finalization-tab'
@@ -168,6 +170,10 @@ function MentoringBulkAssignToolbar({
{ enabled: !isAdminSelected, refetchInterval: 30_000 }, { enabled: !isAdminSelected, refetchInterval: 30_000 },
) )
const count = pending?.count ?? 0 const count = pending?.count ?? 0
const eligibleTotal = pending?.eligibleTotal ?? 0
const mentorPoolSize = pending?.mentorPoolSize ?? 0
const hasNoMentors = mentorPoolSize === 0
const hasNoEligible = eligibleTotal === 0
const bulk = trpc.mentor.autoAssignBulkForRound.useMutation({ const bulk = trpc.mentor.autoAssignBulkForRound.useMutation({
onSuccess: (result) => { onSuccess: (result) => {
@@ -190,23 +196,41 @@ function MentoringBulkAssignToolbar({
auto-fill is disabled. Assign each project manually. auto-fill is disabled. Assign each project manually.
</span> </span>
</> </>
) : hasNoMentors ? (
<span className="text-muted-foreground">
No mentors in the pool yet {' '}
<Link
href="/admin/members?tab=mentors"
className="text-foreground underline-offset-2 hover:underline"
>
add mentors
</Link>{' '}
before auto-filling.
</span>
) : hasNoEligible ? (
<span className="text-muted-foreground">
No projects are eligible for mentorship in this round (
{eligibilityLabel}).
</span>
) : count > 0 ? ( ) : count > 0 ? (
<> <>
<span className="font-medium">{count}</span>{' '} <span className="font-medium">{count}</span>{' '}
<span className="text-muted-foreground"> <span className="text-muted-foreground">
project{count === 1 ? '' : 's'} eligible for auto-fill ({eligibilityLabel}) of {eligibleTotal} project{eligibleTotal === 1 ? '' : 's'} still
needs a mentor ({eligibilityLabel})
</span> </span>
</> </>
) : ( ) : (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
All eligible projects have a mentor. All {eligibleTotal} eligible project{eligibleTotal === 1 ? '' : 's'}{' '}
already have a mentor.
</span> </span>
)} )}
</div> </div>
<Button <Button
size="sm" size="sm"
onClick={() => bulk.mutate({ roundId })} onClick={() => bulk.mutate({ roundId })}
disabled={isAdminSelected || count === 0 || bulk.isPending} disabled={isAdminSelected || count === 0 || hasNoMentors || bulk.isPending}
> >
{bulk.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null} {bulk.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Auto-fill remaining Auto-fill remaining
@@ -1242,6 +1266,20 @@ export default function RoundDetailPage() {
<div> <div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">Project Management</p> <p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">Project Management</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{isMentoring ? (
<button
onClick={() => setActiveTab('projects')}
className="flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full"
>
<Layers className="h-5 w-5 text-[#557f8c] mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Assign Projects</p>
<p className="text-xs text-muted-foreground mt-0.5">
Open the Projects tab to add or auto-fill teams in this round
</p>
</div>
</button>
) : (
<Link href={poolLink}> <Link href={poolLink}>
<button className="flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full"> <button className="flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full">
<Layers className="h-5 w-5 text-[#557f8c] mt-0.5 shrink-0" /> <Layers className="h-5 w-5 text-[#557f8c] mt-0.5 shrink-0" />
@@ -1253,6 +1291,7 @@ export default function RoundDetailPage() {
</div> </div>
</button> </button>
</Link> </Link>
)}
<button <button
onClick={() => setActiveTab('projects')} onClick={() => setActiveTab('projects')}
@@ -1404,6 +1443,7 @@ export default function RoundDetailPage() {
<NotifyAdvancedButton roundId={roundId} /> <NotifyAdvancedButton roundId={roundId} />
<NotifyRejectedButton roundId={roundId} /> <NotifyRejectedButton roundId={roundId} />
<BulkInviteButton roundId={roundId} /> <BulkInviteButton roundId={roundId} />
{isMentoring && <SendMentorshipWelcomeButton roundId={roundId} />}
</div> </div>
</div> </div>
)} )}
@@ -1570,8 +1610,17 @@ export default function RoundDetailPage() {
{/* ═══════════ PROJECTS TAB ═══════════ */} {/* ═══════════ PROJECTS TAB ═══════════ */}
<TabsContent value="projects" className="space-y-4"> <TabsContent value="projects" className="space-y-4">
{isMentoring && ( {isMentoring && (
<>
<MentoringBulkAssignToolbar roundId={roundId} configJson={config} /> <MentoringBulkAssignToolbar roundId={roundId} configJson={config} />
<MentoringProjectsTable
roundId={roundId}
competitionId={competitionId}
competitionRounds={competition?.rounds}
currentSortOrder={round?.sortOrder}
/>
</>
)} )}
{!isMentoring && (
<ProjectStatesTable <ProjectStatesTable
competitionId={competitionId} competitionId={competitionId}
roundId={roundId} roundId={roundId}
@@ -1583,6 +1632,7 @@ export default function RoundDetailPage() {
setTimeout(() => setPreviewSheetOpen(true), 100) setTimeout(() => setPreviewSheetOpen(true), 100)
}} }}
/> />
)}
</TabsContent> </TabsContent>
{/* ═══════════ FILTERING TAB ═══════════ */} {/* ═══════════ FILTERING TAB ═══════════ */}
@@ -2074,39 +2124,39 @@ export default function RoundDetailPage() {
</p> </p>
)} )}
{aiAssignmentMutation.isPending && ( {aiAssignmentMutation.isPending && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-violet-50 border border-violet-200 dark:bg-violet-950/20 dark:border-violet-800"> <div className="flex items-center gap-3 p-3 rounded-lg bg-violet-50 border border-violet-200">
<div className="relative"> <div className="relative">
<div className="h-8 w-8 rounded-full border-2 border-violet-300 border-t-violet-600 animate-spin" /> <div className="h-8 w-8 rounded-full border-2 border-violet-300 border-t-violet-600 animate-spin" />
</div> </div>
<div> <div>
<p className="text-sm font-medium text-violet-800 dark:text-violet-200">AI is analyzing projects and jurors...</p> <p className="text-sm font-medium text-violet-800">AI is analyzing projects and jurors...</p>
<p className="text-xs text-violet-600 dark:text-violet-400"> <p className="text-xs text-violet-600">
Matching expertise, reviewing bios, and balancing workloads Matching expertise, reviewing bios, and balancing workloads
</p> </p>
</div> </div>
</div> </div>
)} )}
{aiAssignmentMutation.error && !aiAssignmentMutation.isPending && ( {aiAssignmentMutation.error && !aiAssignmentMutation.isPending && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-red-50 border border-red-200 dark:bg-red-950/20 dark:border-red-800"> <div className="flex items-center gap-3 p-3 rounded-lg bg-red-50 border border-red-200">
<AlertTriangle className="h-5 w-5 text-red-600 shrink-0" /> <AlertTriangle className="h-5 w-5 text-red-600 shrink-0" />
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium text-red-800 dark:text-red-200"> <p className="text-sm font-medium text-red-800">
AI generation failed AI generation failed
</p> </p>
<p className="text-xs text-red-600 dark:text-red-400"> <p className="text-xs text-red-600">
{aiAssignmentMutation.error.message} {aiAssignmentMutation.error.message}
</p> </p>
</div> </div>
</div> </div>
)} )}
{aiAssignmentMutation.data && !aiAssignmentMutation.isPending && ( {aiAssignmentMutation.data && !aiAssignmentMutation.isPending && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-emerald-50 border border-emerald-200 dark:bg-emerald-950/20 dark:border-emerald-800"> <div className="flex items-center gap-3 p-3 rounded-lg bg-emerald-50 border border-emerald-200">
<CheckCircle2 className="h-5 w-5 text-emerald-600 shrink-0" /> <CheckCircle2 className="h-5 w-5 text-emerald-600 shrink-0" />
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium text-emerald-800 dark:text-emerald-200"> <p className="text-sm font-medium text-emerald-800">
{aiAssignmentMutation.data.stats.assignmentsGenerated} assignments generated {aiAssignmentMutation.data.stats.assignmentsGenerated} assignments generated
</p> </p>
<p className="text-xs text-emerald-600 dark:text-emerald-400"> <p className="text-xs text-emerald-600">
{aiAssignmentMutation.data.stats.totalJurors} jurors, {aiAssignmentMutation.data.stats.totalProjects} projects {aiAssignmentMutation.data.stats.totalJurors} jurors, {aiAssignmentMutation.data.stats.totalProjects} projects
{aiAssignmentMutation.data.fallbackUsed && ' (algorithm fallback)'} {aiAssignmentMutation.data.fallbackUsed && ' (algorithm fallback)'}
</p> </p>
@@ -2588,9 +2638,9 @@ export default function RoundDetailPage() {
{/* Autosave error bar — only shows when save fails */} {/* Autosave error bar — only shows when save fails */}
{autosaveStatus === 'error' && ( {autosaveStatus === 'error' && (
<div className="fixed bottom-0 left-0 right-0 z-50 border-t bg-red-50 dark:bg-red-950/50 shadow-[0_-4px_12px_rgba(0,0,0,0.1)]"> <div className="fixed bottom-0 left-0 right-0 z-50 border-t bg-red-50 shadow-[0_-4px_12px_rgba(0,0,0,0.1)]">
<div className="container flex items-center justify-between py-3 px-4 max-w-5xl mx-auto"> <div className="container flex items-center justify-between py-3 px-4 max-w-5xl mx-auto">
<div className="flex items-center gap-2 text-sm text-red-700 dark:text-red-300"> <div className="flex items-center gap-2 text-sm text-red-700">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<span>Auto-save failed</span> <span>Auto-save failed</span>
</div> </div>

View File

@@ -1,6 +1,8 @@
'use client' 'use client'
import { useState } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { format } from 'date-fns'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { import {
Card, Card,
@@ -9,13 +11,17 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { MentorChat } from '@/components/shared/mentor-chat' import { MentorChat } from '@/components/shared/mentor-chat'
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel' import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
import { RequestChangeDialog } from './request-change-dialog'
import { import {
MessageSquare, MessageSquare,
UserCircle, UserCircle,
FileText, FileText,
UserCog,
} from 'lucide-react' } from 'lucide-react'
export default function ApplicantMentorPage() { export default function ApplicantMentorPage() {
@@ -41,6 +47,8 @@ export default function ApplicantMentorPage() {
}, },
}) })
const [isChangeOpen, setIsChangeOpen] = useState(false)
if (dashLoading) { if (dashLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -72,7 +80,20 @@ export default function ApplicantMentorPage() {
) )
} }
const mentor = dashboardData?.project?.mentorAssignment?.mentor const assignments = dashboardData?.project?.mentorAssignments ?? []
const hasMentors = assignments.length > 0
const primaryAssignment = assignments[0] ?? null
const primaryMentor = primaryAssignment?.mentor
const hasPendingChangeRequest = !!dashboardData?.hasPendingMentorChangeRequest
const dialogMentors = assignments
.filter((a) => !!a.mentor)
.map((a) => ({
assignmentId: a.id,
name: a.mentor?.name || a.mentor?.email || 'Mentor',
}))
const teamHeading = assignments.length > 1 ? 'Your mentor team' : 'Your mentor'
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -83,23 +104,72 @@ export default function ApplicantMentorPage() {
Mentor Communication Mentor Communication
</h1> </h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Chat with your assigned mentor {assignments.length > 1
? 'Chat with your assigned mentor team'
: 'Chat with your assigned mentor'}
</p> </p>
</div> </div>
{/* Mentor info */} {/* Mentor list */}
{mentor ? ( {hasMentors ? (
<Card className="bg-muted/50"> <section className="space-y-3">
<CardContent className="p-4"> <h2 className="text-lg font-semibold tracking-tight">{teamHeading}</h2>
<div className="flex items-center gap-3"> <div className="grid gap-3 md:grid-cols-2">
<UserCircle className="h-10 w-10 text-muted-foreground" /> {assignments.map((assignment) => {
<div> const mentor = assignment.mentor
<p className="font-medium">{mentor.name || 'Mentor'}</p> if (!mentor) return null
<p className="text-sm text-muted-foreground">{mentor.email}</p> const expertise = mentor.expertiseTags ?? []
return (
<Card key={assignment.id} className="bg-muted/50">
<CardContent className="p-4 space-y-3">
<div className="flex items-start gap-3">
<UserCircle className="h-10 w-10 text-muted-foreground shrink-0" />
<div className="min-w-0 flex-1">
<p className="font-medium truncate">
{mentor.name || 'Mentor'}
</p>
<p className="text-sm text-muted-foreground truncate">
{mentor.email}
</p>
{assignment.assignedAt && (
<p className="text-xs text-muted-foreground mt-1">
Assigned since {format(new Date(assignment.assignedAt), 'MMM d, yyyy')}
</p>
)}
</div> </div>
</div> </div>
{expertise.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{expertise.map((tag) => (
<Badge key={tag} variant="secondary" className="font-normal">
{tag}
</Badge>
))}
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
)
})}
</div>
{/* Request change action */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pt-1">
<p className="text-sm text-muted-foreground">
{hasPendingChangeRequest
? "You have a pending mentor change request — admins will follow up soon."
: 'Need a different match? Let the program admins know.'}
</p>
<Button
variant="outline"
onClick={() => setIsChangeOpen(true)}
disabled={hasPendingChangeRequest}
>
<UserCog className="mr-2 h-4 w-4" />
{hasPendingChangeRequest ? 'Change requested' : 'Request a mentor change'}
</Button>
</div>
</section>
) : ( ) : (
<Card className="bg-muted/50"> <Card className="bg-muted/50">
<CardContent className="flex flex-col items-center justify-center py-8"> <CardContent className="flex flex-col items-center justify-center py-8">
@@ -113,12 +183,14 @@ export default function ApplicantMentorPage() {
)} )}
{/* Chat */} {/* Chat */}
{mentor && ( {primaryMentor && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Messages</CardTitle> <CardTitle>Messages</CardTitle>
<CardDescription> <CardDescription>
Your conversation history with {mentor.name || 'your mentor'} {assignments.length > 1
? 'Your conversation history with your mentor team'
: `Your conversation history with ${primaryMentor.name || 'your mentor'}`}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -136,12 +208,23 @@ export default function ApplicantMentorPage() {
)} )}
{/* Files */} {/* Files */}
{dashboardData?.project?.mentorAssignment?.id && ( {primaryAssignment?.id && projectId && (
<WorkspaceFilesPanel <WorkspaceFilesPanel
mentorAssignmentId={dashboardData.project.mentorAssignment.id} projectId={projectId}
mentorAssignmentId={primaryAssignment.id}
asApplicant asApplicant
/> />
)} )}
{/* Request change dialog */}
{projectId && (
<RequestChangeDialog
projectId={projectId}
mentors={dialogMentors}
open={isChangeOpen}
onOpenChange={setIsChangeOpen}
/>
)}
</div> </div>
) )
} }

View File

@@ -0,0 +1,179 @@
'use client'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Loader2 } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
const REASON_MIN = 10
const REASON_MAX = 2000
const TARGET_ANY = '__any__'
type MentorOption = {
assignmentId: string
name: string
}
type RequestChangeDialogProps = {
projectId: string
mentors: MentorOption[]
open: boolean
onOpenChange: (open: boolean) => void
}
export function RequestChangeDialog({
projectId,
mentors,
open,
onOpenChange,
}: RequestChangeDialogProps) {
const [reason, setReason] = useState('')
const [target, setTarget] = useState<string>(TARGET_ANY)
const [touched, setTouched] = useState(false)
const utils = trpc.useUtils()
const requestChange = trpc.mentor.requestChange.useMutation({
onSuccess: async () => {
toast.success(
"Your request has been sent to the program admins. We'll review it and follow up.",
)
onOpenChange(false)
// Refresh dashboard so the disabled state for the button updates.
await utils.applicant.getMyDashboard.invalidate()
},
onError: (error) => {
toast.error(error.message || 'Could not send your request. Please try again.')
},
})
// Reset form when the dialog is closed.
useEffect(() => {
if (!open) {
setReason('')
setTarget(TARGET_ANY)
setTouched(false)
}
}, [open])
const trimmedReason = reason.trim()
const reasonTooShort = trimmedReason.length < REASON_MIN
const reasonTooLong = trimmedReason.length > REASON_MAX
const reasonInvalid = reasonTooShort || reasonTooLong
const showReasonError = touched && reasonInvalid
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setTouched(true)
if (reasonInvalid) return
requestChange.mutate({
projectId,
targetAssignmentId: target === TARGET_ANY ? undefined : target,
reason: trimmedReason,
})
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Request a mentor change</DialogTitle>
<DialogDescription>
Share a few details so the program admins can follow up with you.
Your current mentor will not see this message.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{mentors.length > 0 && (
<div className="space-y-2">
<Label htmlFor="targetMentor">About a specific mentor</Label>
<Select value={target} onValueChange={setTarget}>
<SelectTrigger id="targetMentor">
<SelectValue placeholder="Any / general" />
</SelectTrigger>
<SelectContent>
<SelectItem value={TARGET_ANY}>Any / general</SelectItem>
{mentors.map((m) => (
<SelectItem key={m.assignmentId} value={m.assignmentId}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Optional. Use this if your request is about one of your co-mentors in particular.
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="reason">
Why would you like a change?
</Label>
<Textarea
id="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
onBlur={() => setTouched(true)}
placeholder="Tell us why you'd like a change. The admin team will follow up."
rows={6}
maxLength={REASON_MAX}
aria-invalid={showReasonError || undefined}
required
/>
<div className="flex items-center justify-between text-xs">
{showReasonError ? (
<p className="text-destructive">
{reasonTooShort
? `Please provide at least ${REASON_MIN} characters.`
: `Please keep your message under ${REASON_MAX} characters.`}
</p>
) : (
<p className="text-muted-foreground">
{REASON_MIN}{REASON_MAX} characters.
</p>
)}
<p className="text-muted-foreground tabular-nums">
{trimmedReason.length}/{REASON_MAX}
</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={requestChange.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={requestChange.isPending}>
{requestChange.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Send request
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -219,12 +219,12 @@ export default function ApplicantDashboardPage() {
key={round.id} key={round.id}
className={`flex flex-col sm:flex-row items-start sm:items-center gap-3 rounded-lg border px-4 py-3 ${ className={`flex flex-col sm:flex-row items-start sm:items-center gap-3 rounded-lg border px-4 py-3 ${
isUrgent isUrgent
? 'border-amber-500/50 bg-amber-50 dark:bg-amber-950/20' ? 'border-amber-500/50 bg-amber-50'
: 'border-primary/20 bg-primary/5' : 'border-primary/20 bg-primary/5'
}`} }`}
> >
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<Clock className={`h-4 w-4 shrink-0 ${isUrgent ? 'text-amber-600 dark:text-amber-400' : 'text-primary'}`} /> <Clock className={`h-4 w-4 shrink-0 ${isUrgent ? 'text-amber-600' : 'text-primary'}`} />
<span className="font-medium text-sm truncate">{round.name}</span> <span className="font-medium text-sm truncate">{round.name}</span>
<Badge variant={isUrgent ? 'warning' : 'default'} className="shrink-0"> <Badge variant={isUrgent ? 'warning' : 'default'} className="shrink-0">
{remaining > 0 ? formatCountdown(remaining) + ' left' : 'Closed'} {remaining > 0 ? formatCountdown(remaining) + ' left' : 'Closed'}

View File

@@ -357,15 +357,33 @@ export default function ApplicantProjectPage() {
)} )}
</div> </div>
{/* Mentor info */} {(() => {
{project.mentorAssignment?.mentor && ( type MentorAssignment = {
droppedAt: Date | string | null
mentor: { name: string | null; email: string } | null
}
const active = (
(project.mentorAssignments as MentorAssignment[] | undefined) ?? []
).filter((a) => !a.droppedAt && a.mentor)
if (active.length === 0) return null
return (
<div className="rounded-lg border p-3 bg-muted/50"> <div className="rounded-lg border p-3 bg-muted/50">
<p className="text-sm font-medium mb-1">Assigned Mentor</p> <p className="text-sm font-medium mb-1">
<p className="text-sm text-muted-foreground"> {active.length === 1 ? 'Assigned Mentor' : `Assigned Mentors (${active.length})`}
{project.mentorAssignment.mentor.name} ({project.mentorAssignment.mentor.email})
</p> </p>
</div> <ul className="space-y-0.5">
{active.map((a, idx) => (
<li key={`${a.mentor!.email}-${idx}`} className="text-sm text-muted-foreground">
{a.mentor!.name ?? a.mentor!.email}
{a.mentor!.name && (
<span className="text-xs"> ({a.mentor!.email})</span>
)} )}
</li>
))}
</ul>
</div>
)
})()}
{/* Tags */} {/* Tags */}
{project.tags && project.tags.length > 0 && ( {project.tags && project.tags.length > 0 && (

View File

@@ -427,10 +427,10 @@ function ProjectDetails({ project }: { project: ProjectData }) {
return ( return (
<div className="px-4 pb-4 pt-2 space-y-4 border-t mt-2"> <div className="px-4 pb-4 pt-2 space-y-4 border-t mt-2">
{project.evaluationScore && ( {project.evaluationScore && (
<div className="flex items-center gap-2 rounded-md bg-blue-50/50 dark:bg-blue-950/20 px-3 py-2"> <div className="flex items-center gap-2 rounded-md bg-blue-50/50 px-3 py-2">
<Star className="h-4 w-4 text-blue-600 dark:text-blue-400 shrink-0" /> <Star className="h-4 w-4 text-blue-600 shrink-0" />
<div className="text-sm"> <div className="text-sm">
<span className="font-semibold text-blue-700 dark:text-blue-300"> <span className="font-semibold text-blue-700">
{project.evaluationScore.avg.toFixed(1)} / 10 {project.evaluationScore.avg.toFixed(1)} / 10
</span> </span>
<span className="text-muted-foreground ml-2"> <span className="text-muted-foreground ml-2">
@@ -518,7 +518,7 @@ function ProjectCard({
isExpanded && 'rotate-180' isExpanded && 'rotate-180'
)} /> )} />
<div className="min-w-0"> <div className="min-w-0">
<h3 className="font-semibold text-sm group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors"> <h3 className="font-semibold text-sm group-hover:text-brand-blue transition-colors">
{project.title} {project.title}
</h3> </h3>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
@@ -587,7 +587,7 @@ function ChairPanel({
const isClosed = award.status === 'CLOSED' const isClosed = award.status === 'CLOSED'
return ( return (
<Card className="border-amber-200 dark:border-amber-900"> <Card className="border-amber-200">
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Gavel className="h-5 w-5 text-amber-600" /> <Gavel className="h-5 w-5 text-amber-600" />

View File

@@ -44,7 +44,7 @@ export default function JuryRoundDetailPage() {
Back Back
</Button> </Button>
<div> <div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground"> <h1 className="text-2xl font-bold tracking-tight text-brand-blue">
{round?.name || 'Round Details'} {round?.name || 'Round Details'}
</h1> </h1>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1">

View File

@@ -460,7 +460,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</div> </div>
<Card className="border-l-4 border-l-amber-500"> <Card className="border-l-4 border-l-amber-500">
<CardContent className="flex items-start gap-4 p-6"> <CardContent className="flex items-start gap-4 p-6">
<div className="rounded-xl bg-amber-50 dark:bg-amber-950/40 p-3"> <div className="rounded-xl bg-amber-50 p-3">
<Clock className="h-6 w-6 text-amber-600" /> <Clock className="h-6 w-6 text-amber-600" />
</div> </div>
<div> <div>
@@ -495,7 +495,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</Link> </Link>
</Button> </Button>
<div> <div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground"> <h1 className="text-2xl font-bold tracking-tight text-brand-blue">
Evaluate Project Evaluate Project
</h1> </h1>
<p className="text-muted-foreground mt-1">{project.title}</p> <p className="text-muted-foreground mt-1">{project.title}</p>
@@ -526,7 +526,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</Link> </Link>
</Button> </Button>
<div> <div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground"> <h1 className="text-2xl font-bold tracking-tight text-brand-blue">
Evaluate Project Evaluate Project
</h1> </h1>
<p className="text-muted-foreground mt-1">{project.title}</p> <p className="text-muted-foreground mt-1">{project.title}</p>
@@ -534,7 +534,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</div> </div>
<Card className="border-l-4 border-l-amber-500"> <Card className="border-l-4 border-l-amber-500">
<CardContent className="flex items-start gap-4 p-6"> <CardContent className="flex items-start gap-4 p-6">
<div className="rounded-xl bg-amber-50 dark:bg-amber-950/40 p-3"> <div className="rounded-xl bg-amber-50 p-3">
<ShieldAlert className="h-6 w-6 text-amber-600" /> <ShieldAlert className="h-6 w-6 text-amber-600" />
</div> </div>
<div> <div>
@@ -573,7 +573,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</Button> </Button>
)} )}
<div> <div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground"> <h1 className="text-2xl font-bold tracking-tight text-brand-blue">
{isReadOnly ? 'Submitted Evaluation' : 'Evaluate Project'} {isReadOnly ? 'Submitted Evaluation' : 'Evaluate Project'}
</h1> </h1>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
@@ -583,8 +583,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
variant="secondary" variant="secondary"
className={ className={
project.competitionCategory === 'STARTUP' project.competitionCategory === 'STARTUP'
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300' ? 'bg-violet-100 text-violet-700 border-violet-200'
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300' : 'bg-sky-100 text-sky-700 border-sky-200'
} }
> >
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'} {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
@@ -595,7 +595,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</div> </div>
{isReadOnly && ( {isReadOnly && (
<Card className="border-l-4 border-l-blue-500 bg-blue-50/50 dark:bg-blue-950/20"> <Card className="border-l-4 border-l-blue-500 bg-blue-50/50">
<CardContent className="flex items-start gap-3 p-4"> <CardContent className="flex items-start gap-3 p-4">
<Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" /> <Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" />
<div className="flex-1"> <div className="flex-1">

View File

@@ -98,8 +98,8 @@ export default function JuryProjectDetailPage() {
variant="secondary" variant="secondary"
className={ className={
project.competitionCategory === 'STARTUP' project.competitionCategory === 'STARTUP'
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300' ? 'bg-violet-100 text-violet-700 border-violet-200'
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300' : 'bg-sky-100 text-sky-700 border-sky-200'
} }
> >
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'} {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}

View File

@@ -54,7 +54,7 @@ export default function JuryAssignmentsPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground"> <h1 className="text-2xl font-bold tracking-tight text-brand-blue">
My Assignments My Assignments
</h1> </h1>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1">

View File

@@ -262,7 +262,7 @@ async function JuryDashboardContent() {
<div className="h-1 w-full bg-gradient-to-r from-brand-teal/40 via-brand-blue/40 to-brand-teal/40" /> <div className="h-1 w-full bg-gradient-to-r from-brand-teal/40 via-brand-blue/40 to-brand-teal/40" />
<CardContent className="py-8 px-6"> <CardContent className="py-8 px-6">
<div className="flex flex-col items-center text-center mb-6"> <div className="flex flex-col items-center text-center mb-6">
<div className="rounded-2xl bg-gradient-to-br from-brand-teal/10 to-brand-blue/10 p-4 mb-3 dark:from-brand-teal/20 dark:to-brand-blue/20"> <div className="rounded-2xl bg-gradient-to-br from-brand-teal/10 to-brand-blue/10 p-4 mb-3">
<ClipboardList className="h-8 w-8 text-brand-teal/60" /> <ClipboardList className="h-8 w-8 text-brand-teal/60" />
</div> </div>
<p className="text-lg font-semibold">No assignments yet</p> <p className="text-lg font-semibold">No assignments yet</p>
@@ -273,13 +273,13 @@ async function JuryDashboardContent() {
<div className={`grid gap-3 max-w-md mx-auto ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}> <div className={`grid gap-3 max-w-md mx-auto ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}>
<Link <Link
href="/jury/competitions" href="/jury/competitions"
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5" className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md"
> >
<div className="rounded-lg bg-blue-50 p-2 transition-colors group-hover:bg-blue-100 dark:bg-blue-950/40"> <div className="rounded-lg bg-blue-50 p-2 transition-colors group-hover:bg-blue-100">
<ClipboardList className="h-4 w-4 text-blue-600 dark:text-blue-400" /> <ClipboardList className="h-4 w-4 text-blue-600" />
</div> </div>
<div className="text-left"> <div className="text-left">
<p className="font-semibold text-sm group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">All Assignments</p> <p className="font-semibold text-sm group-hover:text-brand-blue transition-colors">All Assignments</p>
<p className="text-xs text-muted-foreground">View evaluations</p> <p className="text-xs text-muted-foreground">View evaluations</p>
</div> </div>
</Link> </Link>
@@ -288,7 +288,7 @@ async function JuryDashboardContent() {
href="/jury/competitions" href="/jury/competitions"
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md" className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
> >
<div className="rounded-lg bg-teal-50 p-2 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40"> <div className="rounded-lg bg-teal-50 p-2 transition-colors group-hover:bg-teal-100">
<GitCompare className="h-4 w-4 text-brand-teal" /> <GitCompare className="h-4 w-4 text-brand-teal" />
</div> </div>
<div className="text-left"> <div className="text-left">
@@ -314,8 +314,8 @@ async function JuryDashboardContent() {
<div className="rounded-[7px] bg-background"> <div className="rounded-[7px] bg-background">
<CardHeader className="pb-2 pt-4 px-5"> <CardHeader className="pb-2 pt-4 px-5">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="rounded-lg bg-amber-100 p-1.5 dark:bg-amber-900/40"> <div className="rounded-lg bg-amber-100 p-1.5">
<Trophy className="h-4 w-4 text-amber-600 dark:text-amber-400" /> <Trophy className="h-4 w-4 text-amber-600" />
</div> </div>
<CardTitle className="text-lg">Special Awards Voting Open</CardTitle> <CardTitle className="text-lg">Special Awards Voting Open</CardTitle>
</div> </div>
@@ -333,27 +333,27 @@ async function JuryDashboardContent() {
className={cn( className={cn(
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md', 'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
hasVoted hasVoted
? 'border-green-200/60 bg-green-50/30 dark:border-green-800/40 dark:bg-green-950/10' ? 'border-green-200/60 bg-green-50/30'
: isUrgent : isUrgent
? 'border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20' ? 'border-red-200 bg-red-50/50'
: 'border-amber-200/60 bg-amber-50/30 dark:border-amber-800/40 dark:bg-amber-950/10' : 'border-amber-200/60 bg-amber-50/30'
)} )}
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<h3 className={cn('font-semibold', hasVoted ? 'text-green-700 dark:text-green-400' : 'text-amber-700 dark:text-amber-400')}>{award.name}</h3> <h3 className={cn('font-semibold', hasVoted ? 'text-green-700' : 'text-amber-700')}>{award.name}</h3>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{award._count.eligibilities} project{award._count.eligibilities !== 1 ? 's' : ''} to review {award._count.eligibilities} project{award._count.eligibilities !== 1 ? 's' : ''} to review
{record.isChair && ' · You are the Chair'} {record.isChair && ' · You are the Chair'}
</p> </p>
</div> </div>
{hasVoted ? ( {hasVoted ? (
<Badge className="bg-green-100 text-green-800 border-green-300 dark:bg-green-950 dark:text-green-300 dark:border-green-700"> <Badge className="bg-green-100 text-green-800 border-green-300">
<CheckCircle2 className="mr-1 h-3 w-3" /> <CheckCircle2 className="mr-1 h-3 w-3" />
Submitted Submitted
</Badge> </Badge>
) : ( ) : (
<Badge className="bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-700"> <Badge className="bg-amber-100 text-amber-800 border-amber-300">
Vote Now Vote Now
</Badge> </Badge>
)} )}
@@ -452,8 +452,8 @@ async function JuryDashboardContent() {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="rounded-lg bg-brand-blue/10 p-1.5 dark:bg-brand-blue/20"> <div className="rounded-lg bg-brand-blue/10 p-1.5">
<ClipboardList className="h-4 w-4 text-brand-blue dark:text-brand-teal" /> <ClipboardList className="h-4 w-4 text-brand-blue" />
</div> </div>
<CardTitle className="text-lg">My Assignments</CardTitle> <CardTitle className="text-lg">My Assignments</CardTitle>
</div> </div>
@@ -487,14 +487,14 @@ async function JuryDashboardContent() {
href={`/jury/competitions/${assignment.round.id}/projects/${assignment.project.id}`} href={`/jury/competitions/${assignment.round.id}/projects/${assignment.project.id}`}
className="flex-1 min-w-0 group" className="flex-1 min-w-0 group"
> >
<p className="text-sm font-medium truncate group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors"> <p className="text-sm font-medium truncate group-hover:text-brand-blue transition-colors">
{assignment.project.title} {assignment.project.title}
</p> </p>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<span className="text-xs text-muted-foreground truncate"> <span className="text-xs text-muted-foreground truncate">
{assignment.project.teamName} {assignment.project.teamName}
</span> </span>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-brand-blue/5 text-brand-blue/80 dark:bg-brand-teal/10 dark:text-brand-teal/80 border-0"> <Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-brand-blue/5 text-brand-blue/80 border-0">
{assignment.round.name} {assignment.round.name}
</Badge> </Badge>
</div> </div>
@@ -506,7 +506,7 @@ async function JuryDashboardContent() {
Done Done
</Badge> </Badge>
) : isDraft && isVotingOpen ? ( ) : isDraft && isVotingOpen ? (
<Badge className="text-xs bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-700 animate-pulse"> <Badge className="text-xs bg-amber-100 text-amber-800 border-amber-300 animate-pulse">
<Send className="mr-1 h-3 w-3" /> <Send className="mr-1 h-3 w-3" />
Ready to submit Ready to submit
</Badge> </Badge>
@@ -571,7 +571,7 @@ async function JuryDashboardContent() {
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="rounded-lg bg-brand-teal/10 p-1.5 dark:bg-brand-teal/20"> <div className="rounded-lg bg-brand-teal/10 p-1.5">
<Zap className="h-4 w-4 text-brand-teal" /> <Zap className="h-4 w-4 text-brand-teal" />
</div> </div>
<CardTitle className="text-lg">Quick Actions</CardTitle> <CardTitle className="text-lg">Quick Actions</CardTitle>
@@ -581,13 +581,13 @@ async function JuryDashboardContent() {
<div className={`grid gap-3 ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}> <div className={`grid gap-3 ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}>
<Link <Link
href="/jury/competitions" href="/jury/competitions"
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5" className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md"
> >
<div className="rounded-xl bg-blue-50 p-3 transition-colors group-hover:bg-blue-100 dark:bg-blue-950/40 dark:group-hover:bg-blue-950/60"> <div className="rounded-xl bg-blue-50 p-3 transition-colors group-hover:bg-blue-100">
<ClipboardList className="h-5 w-5 text-blue-600 dark:text-blue-400" /> <ClipboardList className="h-5 w-5 text-blue-600" />
</div> </div>
<div className="text-left"> <div className="text-left">
<p className="font-semibold text-sm group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">All Assignments</p> <p className="font-semibold text-sm group-hover:text-brand-blue transition-colors">All Assignments</p>
<p className="text-xs text-muted-foreground mt-0.5">View and manage evaluations</p> <p className="text-xs text-muted-foreground mt-0.5">View and manage evaluations</p>
</div> </div>
</Link> </Link>
@@ -596,7 +596,7 @@ async function JuryDashboardContent() {
href="/jury/competitions" href="/jury/competitions"
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md" className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
> >
<div className="rounded-xl bg-teal-50 p-3 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40 dark:group-hover:bg-teal-950/60"> <div className="rounded-xl bg-teal-50 p-3 transition-colors group-hover:bg-teal-100">
<GitCompare className="h-5 w-5 text-brand-teal" /> <GitCompare className="h-5 w-5 text-brand-teal" />
</div> </div>
<div className="text-left"> <div className="text-left">
@@ -620,8 +620,8 @@ async function JuryDashboardContent() {
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" /> <div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="rounded-lg bg-brand-blue/10 p-1.5 dark:bg-brand-blue/20"> <div className="rounded-lg bg-brand-blue/10 p-1.5">
<Waves className="h-4 w-4 text-brand-blue dark:text-brand-teal" /> <Waves className="h-4 w-4 text-brand-blue" />
</div> </div>
<div> <div>
<CardTitle className="text-lg">Active Voting Stages</CardTitle> <CardTitle className="text-lg">Active Voting Stages</CardTitle>
@@ -650,13 +650,13 @@ async function JuryDashboardContent() {
className={cn( className={cn(
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md', 'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
isUrgent isUrgent
? 'border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20' ? 'border-red-200 bg-red-50/50'
: 'border-border/60 bg-muted/20 dark:bg-muted/10' : 'border-border/60 bg-muted/20'
)} )}
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<h3 className="font-semibold text-brand-blue dark:text-brand-teal">{round.name}</h3> <h3 className="font-semibold text-brand-blue">{round.name}</h3>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{program.name} &middot; {program.year} {program.name} &middot; {program.year}
</p> </p>
@@ -716,7 +716,7 @@ async function JuryDashboardContent() {
<AnimatedCard index={8}> <AnimatedCard index={8}>
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-6 text-center"> <CardContent className="flex flex-col items-center justify-center py-6 text-center">
<div className="rounded-2xl bg-brand-teal/10 p-3 mb-2 dark:bg-brand-teal/20"> <div className="rounded-2xl bg-brand-teal/10 p-3 mb-2">
<Clock className="h-6 w-6 text-brand-teal/70" /> <Clock className="h-6 w-6 text-brand-teal/70" />
</div> </div>
<p className="font-semibold text-sm">No active voting stages</p> <p className="font-semibold text-sm">No active voting stages</p>
@@ -734,7 +734,7 @@ async function JuryDashboardContent() {
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="rounded-lg bg-brand-teal/10 p-1.5 dark:bg-brand-teal/20"> <div className="rounded-lg bg-brand-teal/10 p-1.5">
<BarChart3 className="h-4 w-4 text-brand-teal" /> <BarChart3 className="h-4 w-4 text-brand-teal" />
</div> </div>
<CardTitle className="text-lg">Round Summary</CardTitle> <CardTitle className="text-lg">Round Summary</CardTitle>
@@ -750,7 +750,7 @@ async function JuryDashboardContent() {
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="font-medium truncate">{round.name}</span> <span className="font-medium truncate">{round.name}</span>
<div className="flex items-baseline gap-1 shrink-0 ml-2"> <div className="flex items-baseline gap-1 shrink-0 ml-2">
<span className="font-bold tabular-nums text-brand-blue dark:text-brand-teal">{pct}%</span> <span className="font-bold tabular-nums text-brand-blue">{pct}%</span>
<span className="text-xs text-muted-foreground">({done}/{total})</span> <span className="text-xs text-muted-foreground">({done}/{total})</span>
</div> </div>
</div> </div>
@@ -852,7 +852,7 @@ export default async function JuryDashboardPage() {
<div className="space-y-4"> <div className="space-y-4">
{/* Header */} {/* Header */}
<div> <div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground"> <h1 className="text-2xl font-bold tracking-tight text-brand-blue">
{getGreeting()}, {session?.user?.name || 'Juror'} {getGreeting()}, {session?.user?.name || 'Juror'}
</h1> </h1>
<p className="text-muted-foreground mt-0.5"> <p className="text-muted-foreground mt-0.5">

View File

@@ -94,14 +94,18 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
}, },
}) })
// TODO(PR8 Task 9): show co-mentors. For now we pick the first assignment
// to keep tracking + chat working unchanged.
const primaryAssignment = project?.mentorAssignments?.[0] ?? null
// Track view when project loads // Track view when project loads
const trackView = trpc.mentor.trackView.useMutation() const trackView = trpc.mentor.trackView.useMutation()
useEffect(() => { useEffect(() => {
if (project?.mentorAssignment?.id) { if (primaryAssignment?.id) {
trackView.mutate({ mentorAssignmentId: project.mentorAssignment.id }) trackView.mutate({ mentorAssignmentId: primaryAssignment.id })
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [project?.mentorAssignment?.id]) }, [primaryAssignment?.id])
if (isLoading) { if (isLoading) {
return <ProjectDetailSkeleton /> return <ProjectDetailSkeleton />
@@ -135,7 +139,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD') const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD')
const otherMembers = project.teamMembers?.filter((m) => m.role !== 'LEAD') || [] const otherMembers = project.teamMembers?.filter((m) => m.role !== 'LEAD') || []
const mentorAssignment = project.mentorAssignment const mentorAssignment = primaryAssignment
const mentorAssignmentId = mentorAssignment?.id const mentorAssignmentId = mentorAssignment?.id
const programId = project.program?.id const programId = project.program?.id
const viewerIsAssignedMentor = const viewerIsAssignedMentor =
@@ -340,6 +344,25 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{(() => {
const emails = (project.teamMembers ?? [])
.map((m) => m.user.email)
.filter((e): e is string => !!e)
if (emails.length === 0) return null
const mailto = `mailto:${emails.join(',')}?subject=${encodeURIComponent(
`MOPC Mentorship — ${project.title}`,
)}`
return (
<div className="flex justify-end">
<Button variant="outline" size="sm" asChild>
<a href={mailto}>
<Mail className="mr-2 h-4 w-4" />
Email all team members
</a>
</Button>
</div>
)
})()}
{/* Team Lead */} {/* Team Lead */}
{teamLead && ( {teamLead && (
<div className="p-4 rounded-lg border bg-muted/30"> <div className="p-4 rounded-lg border bg-muted/30">
@@ -477,7 +500,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<CardContent> <CardContent>
<MentorChat <MentorChat
messages={mentorMessages || []} messages={mentorMessages || []}
currentUserId={project.mentorAssignment?.mentor?.id || ''} currentUserId={primaryAssignment?.mentor?.id || ''}
onSendMessage={async (message) => { onSendMessage={async (message) => {
await sendMessage.mutateAsync({ projectId, message }) await sendMessage.mutateAsync({ projectId, message })
}} }}
@@ -592,7 +615,7 @@ function MilestonesSection({
<div <div
key={milestone.id} key={milestone.id}
className={`flex items-start gap-3 p-3 rounded-lg border transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm ${ className={`flex items-start gap-3 p-3 rounded-lg border transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm ${
isCompleted ? 'bg-green-50/50 border-green-200 dark:bg-green-950/20 dark:border-green-900' : '' isCompleted ? 'bg-green-50/50 border-green-200' : ''
}`} }`}
> >
<Checkbox <Checkbox

View File

@@ -1,21 +1,29 @@
'use client' 'use client'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { WorkspaceChat } from '@/components/mentor/workspace-chat' import { WorkspaceChat } from '@/components/mentor/workspace-chat'
import { FilePromotionPanel } from '@/components/mentor/file-promotion-panel' import { FilePromotionPanel } from '@/components/mentor/file-promotion-panel'
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel' import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
import { ArrowLeft, MessageSquare, FileText, Upload } from 'lucide-react' import { ArrowLeft, MessageSquare, FileText, Upload, Users } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
export default function MentorWorkspaceDetailPage() { export default function MentorWorkspaceDetailPage() {
const params = useParams() const params = useParams()
const router = useRouter() const router = useRouter()
const { data: session } = useSession()
const projectId = params.projectId as string const projectId = params.projectId as string
// Get mentor assignment for this project // Get mentor assignment for this project
@@ -27,6 +35,22 @@ export default function MentorWorkspaceDetailPage() {
{ enabled: !!projectId } { enabled: !!projectId }
) )
// Co-mentor visibility (PR8 multi-mentor): show who else is on the team.
// Gracefully tolerates stale tabs where the caller no longer has access
// (assignment dropped) — query just returns nothing in that case.
const { data: projectMentors } = trpc.mentor.getProjectMentors.useQuery(
{ projectId },
{ enabled: !!projectId, retry: false }
)
const currentUserId = session?.user?.id
const coMentors = (projectMentors ?? []).filter(
a => a.mentor.id !== currentUserId
)
const coMentorNames = coMentors.map(a => a.mentor.name ?? 'Unnamed mentor')
const visibleCoMentors = coMentorNames.slice(0, 3)
const hiddenCoMentors = coMentorNames.slice(3)
if (isLoading) { if (isLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -70,6 +94,37 @@ export default function MentorWorkspaceDetailPage() {
{project.teamName && ( {project.teamName && (
<p className="text-muted-foreground mt-1">{project.teamName}</p> <p className="text-muted-foreground mt-1">{project.teamName}</p>
)} )}
{coMentors.length > 0 && (
<div className="mt-2 flex items-center gap-1.5 text-sm text-muted-foreground">
<Users className="h-3.5 w-3.5 shrink-0" />
<span>
You + {coMentors.length} co-mentor
{coMentors.length === 1 ? '' : 's'}:{' '}
<span className="text-foreground">
{visibleCoMentors.join(', ')}
</span>
{hiddenCoMentors.length > 0 && (
<>
{' '}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-help underline decoration-dotted underline-offset-2">
+{hiddenCoMentors.length} more
</span>
</TooltipTrigger>
<TooltipContent>
<div className="text-xs">
{hiddenCoMentors.join(', ')}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
)}
</span>
</div>
)}
</div> </div>
</div> </div>
@@ -104,7 +159,10 @@ export default function MentorWorkspaceDetailPage() {
<TabsContent value="files" className="mt-6"> <TabsContent value="files" className="mt-6">
{assignment ? ( {assignment ? (
<WorkspaceFilesPanel mentorAssignmentId={assignment.id} /> <WorkspaceFilesPanel
projectId={projectId}
mentorAssignmentId={assignment.id}
/>
) : ( ) : (
<Card> <Card>
<CardContent className="text-center py-8"> <CardContent className="text-center py-8">
@@ -117,7 +175,7 @@ export default function MentorWorkspaceDetailPage() {
<TabsContent value="promotion" className="mt-6"> <TabsContent value="promotion" className="mt-6">
{assignment ? ( {assignment ? (
<FilePromotionPanel mentorAssignmentId={assignment.id} /> <FilePromotionPanel projectId={projectId} />
) : ( ) : (
<Card> <Card>
<CardContent className="text-center py-8"> <CardContent className="text-center py-8">

View File

@@ -280,7 +280,7 @@ function FinalistConfirmContent({ token }: { token: string }) {
Your project <strong>{data.project.title}</strong> is a finalist for the Monaco Ocean Your project <strong>{data.project.title}</strong> is a finalist for the Monaco Ocean
Protection Challenge grand finale. Protection Challenge grand finale.
</p> </p>
<div className="bg-background border-amber-300 mt-3 rounded-md border-l-4 p-3 dark:border-amber-700"> <div className="bg-background border-amber-300 mt-3 rounded-md border-l-4 p-3">
<p className="text-sm"> <p className="text-sm">
<strong>Confirm by {formatDeadline(deadline)}.</strong> <strong>Confirm by {formatDeadline(deadline)}.</strong>
</p> </p>

View File

@@ -218,35 +218,6 @@
--info: 194 25% 44%; --info: 194 25% 44%;
} }
.dark {
--background: 220 15% 8%;
--foreground: 0 0% 98%;
--card: 220 15% 10%;
--card-foreground: 0 0% 98%;
--popover: 220 15% 10%;
--popover-foreground: 0 0% 98%;
--primary: 354 90% 50%;
--primary-foreground: 0 0% 100%;
--secondary: 220 15% 18%;
--secondary-foreground: 0 0% 98%;
--muted: 220 15% 18%;
--muted-foreground: 0 0% 64%;
--accent: 194 20% 18%;
--accent-foreground: 0 0% 98%;
--destructive: 0 84% 55%;
--destructive-foreground: 0 0% 100%;
--border: 220 15% 22%;
--input: 220 15% 22%;
--ring: 220 10% 50%;
}
} }
@layer base { @layer base {
@@ -345,13 +316,6 @@ div[class*="recharts-tooltip"] {
opacity: 1 !important; opacity: 1 !important;
} }
.dark div[class*="tremor"][class*="tooltip"],
.dark .recharts-tooltip-wrapper .recharts-default-tooltip,
.dark div[class*="recharts-tooltip"] {
background-color: hsl(var(--card)) !important;
border-color: hsl(var(--border)) !important;
}
/* Tremor/Recharts tooltip color indicator icons — fix rendering */ /* Tremor/Recharts tooltip color indicator icons — fix rendering */
.recharts-tooltip-wrapper svg.recharts-surface { .recharts-tooltip-wrapper svg.recharts-surface {
display: inline-block !important; display: inline-block !important;

View File

@@ -2,7 +2,6 @@
import { useState } from 'react' import { useState } from 'react'
import { SessionProvider } from 'next-auth/react' import { SessionProvider } from 'next-auth/react'
import { ThemeProvider } from 'next-themes'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client' import { httpBatchLink } from '@trpc/client'
import superjson from 'superjson' import superjson from 'superjson'
@@ -78,12 +77,10 @@ export function Providers({ children }: { children: React.ReactNode }) {
) )
return ( return (
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
<SessionProvider> <SessionProvider>
<trpc.Provider client={trpcClient} queryClient={queryClient}> <trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider> <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider> </trpc.Provider>
</SessionProvider> </SessionProvider>
</ThemeProvider>
) )
} }

View File

@@ -437,7 +437,7 @@ export function AssignmentPreviewSheet({
{mode === 'ai' && !aiResult && !isAIGenerating && ( {mode === 'ai' && !aiResult && !isAIGenerating && (
<Card className="border-dashed"> <Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-8 gap-3"> <CardContent className="flex flex-col items-center justify-center py-8 gap-3">
<div className="h-12 w-12 rounded-full bg-violet-100 dark:bg-violet-950 flex items-center justify-center"> <div className="h-12 w-12 rounded-full bg-violet-100 flex items-center justify-center">
<Sparkles className="h-6 w-6 text-violet-600" /> <Sparkles className="h-6 w-6 text-violet-600" />
</div> </div>
<div className="text-center space-y-1"> <div className="text-center space-y-1">
@@ -463,7 +463,7 @@ export function AssignmentPreviewSheet({
{isLoading ? ( {isLoading ? (
<div className="space-y-3"> <div className="space-y-3">
{mode === 'ai' && ( {mode === 'ai' && (
<Card className="border-violet-200 bg-violet-50/50 dark:bg-violet-950/20"> <Card className="border-violet-200 bg-violet-50/50">
<CardContent className="flex items-center gap-3 py-4"> <CardContent className="flex items-center gap-3 py-4">
<div className="relative"> <div className="relative">
<div className="h-6 w-6 rounded-full border-2 border-violet-300 border-t-violet-600 animate-spin" /> <div className="h-6 w-6 rounded-full border-2 border-violet-300 border-t-violet-600 animate-spin" />
@@ -567,13 +567,13 @@ export function AssignmentPreviewSheet({
{/* ── Warnings ── */} {/* ── Warnings ── */}
{preview.warnings && preview.warnings.length > 0 && ( {preview.warnings && preview.warnings.length > 0 && (
<Card className="border-amber-300 bg-amber-50/50 dark:bg-amber-950/20"> <Card className="border-amber-300 bg-amber-50/50">
<CardContent className="p-3"> <CardContent className="p-3">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 shrink-0" /> <AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 shrink-0" />
<div className="space-y-1"> <div className="space-y-1">
{preview.warnings.map((w: string, idx: number) => ( {preview.warnings.map((w: string, idx: number) => (
<p key={idx} className="text-xs text-amber-800 dark:text-amber-200"> <p key={idx} className="text-xs text-amber-800">
{w} {w}
</p> </p>
))} ))}

View File

@@ -259,7 +259,7 @@ export function AwardShortlist({
} }
</p> </p>
{eligibilityMode === 'SEPARATE_POOL' && !hasAwardRounds && ( {eligibilityMode === 'SEPARATE_POOL' && !hasAwardRounds && (
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300"> <div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" /> <AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<p className="text-sm"> <p className="text-sm">
No award rounds have been created yet. Projects will be confirmed but <strong>not routed</strong> to an evaluation track. Create rounds on the award page first. No award rounds have been created yet. Projects will be confirmed but <strong>not routed</strong> to an evaluation track. Create rounds on the award page first.

View File

@@ -328,13 +328,13 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
<div className="space-y-6"> <div className="space-y-6">
{/* Grace Period Banner */} {/* Grace Period Banner */}
{summary.isGracePeriodActive && ( {summary.isGracePeriodActive && (
<Card className="border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/20"> <Card className="border-amber-200 bg-amber-50">
<CardContent className="flex items-center justify-between py-4"> <CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Clock className="h-5 w-5 text-amber-600" /> <Clock className="h-5 w-5 text-amber-600" />
<div> <div>
<p className="font-medium text-amber-800 dark:text-amber-200">Grace Period Active</p> <p className="font-medium text-amber-800">Grace Period Active</p>
<p className="text-sm text-amber-600 dark:text-amber-400"> <p className="text-sm text-amber-600">
Applicants can still submit until{' '} Applicants can still submit until{' '}
{summary.gracePeriodEndsAt {summary.gracePeriodEndsAt
? new Date(summary.gracePeriodEndsAt).toLocaleString() ? new Date(summary.gracePeriodEndsAt).toLocaleString()
@@ -358,12 +358,12 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
{/* Finalized Banner */} {/* Finalized Banner */}
{summary.isFinalized && ( {summary.isFinalized && (
<Card className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/20"> <Card className="border-green-200 bg-green-50">
<CardContent className="flex items-center gap-3 py-4"> <CardContent className="flex items-center gap-3 py-4">
<CheckCircle2 className="h-5 w-5 text-green-600" /> <CheckCircle2 className="h-5 w-5 text-green-600" />
<div> <div>
<p className="font-medium text-green-800 dark:text-green-200">Round Finalized</p> <p className="font-medium text-green-800">Round Finalized</p>
<p className="text-sm text-green-600 dark:text-green-400"> <p className="text-sm text-green-600">
Finalized on{' '} Finalized on{' '}
{summary.finalizedAt {summary.finalizedAt
? new Date(summary.finalizedAt).toLocaleString() ? new Date(summary.finalizedAt).toLocaleString()
@@ -376,13 +376,13 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
{/* Needs Processing Banner */} {/* Needs Processing Banner */}
{!summary.isFinalized && !summary.isGracePeriodActive && summary.projects.length > 0 && summary.projects.every((p) => !p.proposedOutcome) && ( {!summary.isFinalized && !summary.isGracePeriodActive && summary.projects.length > 0 && summary.projects.every((p) => !p.proposedOutcome) && (
<Card className="border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/20"> <Card className="border-blue-200 bg-blue-50">
<CardContent className="flex items-center justify-between py-4"> <CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<AlertTriangle className="h-5 w-5 text-blue-600" /> <AlertTriangle className="h-5 w-5 text-blue-600" />
<div> <div>
<p className="font-medium text-blue-800 dark:text-blue-200">Projects Need Processing</p> <p className="font-medium text-blue-800">Projects Need Processing</p>
<p className="text-sm text-blue-600 dark:text-blue-400"> <p className="text-sm text-blue-600">
{summary.projects.length} project{summary.projects.length !== 1 ? 's' : ''} in this round have no proposed outcome. {summary.projects.length} project{summary.projects.length !== 1 ? 's' : ''} in this round have no proposed outcome.
Click &quot;Process&quot; to auto-assign outcomes based on round type and project activity. Click &quot;Process&quot; to auto-assign outcomes based on round type and project activity.
</p> </p>

View File

@@ -0,0 +1,738 @@
'use client'
import { useMemo, useState } from 'react'
import Link from 'next/link'
import { toast } from 'sonner'
import { trpc } from '@/lib/trpc/client'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
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 { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Search,
UserPlus,
ArrowRight,
Sparkles,
Loader2,
Download,
X,
Plus,
} from 'lucide-react'
import { CountryDisplay } from '@/components/shared/country-display'
import { AddProjectDialog } from '@/components/admin/round/project-states-table'
type Filter = 'all' | 'unassigned' | 'assigned' | 'wants_only'
type CompetitionRound = {
id: string
name: string
sortOrder: number
_count: { projectRoundStates: number }
}
export function MentoringProjectsTable({
roundId,
competitionId,
competitionRounds,
currentSortOrder,
}: {
roundId: string
competitionId: string
competitionRounds?: CompetitionRound[]
currentSortOrder?: number
}) {
const [addProjectOpen, setAddProjectOpen] = useState(false)
const [search, setSearch] = useState('')
const [filter, setFilter] = useState<Filter>('all')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [bulkOpen, setBulkOpen] = useState(false)
const [chosenMentorIds, setChosenMentorIds] = useState<Set<string>>(new Set())
const [mentorSearch, setMentorSearch] = useState('')
const utils = trpc.useUtils()
const { data, isLoading } = trpc.round.listMentoringProjects.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const { data: importCandidates } =
trpc.round.getMentoringImportCandidates.useQuery({ roundId })
const { data: mentorPool } = trpc.mentor.getMentorPool.useQuery(
{},
{ enabled: bulkOpen },
)
const bulkAssignMutation = trpc.mentor.bulkAssign.useMutation({
onSuccess: (result) => {
if (result.totalAssigned === 0 && result.totalSkipped > 0) {
toast.info(
`No new assignments — every selected mentor is already on every selected project (${result.totalSkipped} pair${result.totalSkipped === 1 ? '' : 's'} skipped).`,
)
} else if (result.totalAssigned === 0 && result.ineligibleProjectCount > 0) {
toast.warning(
`${result.ineligibleProjectCount} project${result.ineligibleProjectCount === 1 ? '' : 's'} aren't in a mentoring round and were skipped.`,
)
} else {
const mentorCount = result.perMentor.filter((m) => m.assigned > 0).length
toast.success(
`Created ${result.totalAssigned} assignment${
result.totalAssigned === 1 ? '' : 's'
} across ${result.touchedProjectCount} project${
result.touchedProjectCount === 1 ? '' : 's'
}${result.totalSkipped > 0 ? ` (${result.totalSkipped} pair${result.totalSkipped === 1 ? '' : 's'} already existed)` : ''}${
result.emailsSent > 0
? ` · ${result.emailsSent} mentor email${result.emailsSent === 1 ? '' : 's'} sent`
: ''
}`,
{
description:
mentorCount > 1
? `Each of ${mentorCount} mentors gets a single combined email listing only their new projects.`
: undefined,
},
)
}
utils.round.listMentoringProjects.invalidate({ roundId })
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
utils.round.getMentoringImportCandidates.invalidate({ roundId })
utils.mentor.getMentorPool.invalidate()
utils.mentor.getRoundStats.invalidate({ roundId })
utils.project.list.invalidate()
setSelected(new Set())
setChosenMentorIds(new Set())
setMentorSearch('')
setBulkOpen(false)
},
onError: (err) => toast.error(err.message),
})
const advanceMutation = trpc.round.advanceProjects.useMutation({
onSuccess: (result) => {
toast.success(
`Imported ${result.advancedCount} project${
result.advancedCount === 1 ? '' : 's'
} from ${result.targetRoundName ? '' : ''}${
importCandidates?.priorRound?.name ?? 'the prior round'
}`,
)
utils.round.listMentoringProjects.invalidate({ roundId })
utils.round.getMentoringImportCandidates.invalidate({ roundId })
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
},
onError: (err) => toast.error(err.message),
})
const importBanner = importCandidates?.priorRound &&
importCandidates.pendingCount > 0 && (
<div className="flex flex-col gap-2 rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm sm:flex-row sm:items-center sm:justify-between">
<div className="text-amber-900">
<span className="font-medium">
{importCandidates.pendingCount} PASSED project
{importCandidates.pendingCount === 1 ? '' : 's'}
</span>{' '}
from{' '}
<span className="font-medium">
{importCandidates.priorRound.name}
</span>{' '}
{importCandidates.pendingCount === 1 ? "isn't" : "aren't"} in this
mentoring round yet.
</div>
<Button
size="sm"
onClick={() =>
advanceMutation.mutate({
roundId: importCandidates.priorRound!.id,
targetRoundId: roundId,
})
}
disabled={advanceMutation.isPending}
>
{advanceMutation.isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<Download className="mr-1.5 h-4 w-4" />
)}
Import {importCandidates.pendingCount}
</Button>
</div>
)
const filtered = useMemo(() => {
if (!data) return []
const q = search.trim().toLowerCase()
return data.projects.filter((p) => {
if (filter === 'unassigned' && p.mentors.length > 0) return false
if (filter === 'assigned' && p.mentors.length === 0) return false
if (filter === 'wants_only' && !p.wantsMentorship) return false
if (!q) return true
const hay = [
p.title,
p.teamName ?? '',
p.country ?? '',
...p.mentors.map((m) => m.name ?? m.email),
]
.join(' ')
.toLowerCase()
return hay.includes(q)
})
}, [data, search, filter])
const totals = useMemo(() => {
if (!data)
return { total: 0, unassigned: 0, assigned: 0, wants: 0 }
return {
total: data.projects.length,
unassigned: data.projects.filter((p) => p.mentors.length === 0).length,
assigned: data.projects.filter((p) => p.mentors.length > 0).length,
wants: data.projects.filter((p) => p.wantsMentorship).length,
}
}, [data])
if (isLoading) {
return (
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)
}
if (!data || data.projects.length === 0) {
return (
<div className="space-y-3">
{importBanner}
<div className="flex items-center justify-end">
<Button size="sm" onClick={() => setAddProjectOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" />
Add Project to Round
</Button>
</div>
<div className="rounded-md border bg-muted/30 px-4 py-12 text-center text-sm text-muted-foreground">
No projects in this mentoring round yet. Click{' '}
<span className="font-medium text-foreground">Add Project to Round</span>{' '}
above to populate it.
</div>
<AddProjectDialog
open={addProjectOpen}
onOpenChange={setAddProjectOpen}
roundId={roundId}
competitionId={competitionId}
competitionRounds={competitionRounds}
currentSortOrder={currentSortOrder}
onAssigned={() => {
utils.round.listMentoringProjects.invalidate({ roundId })
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
utils.round.getMentoringImportCandidates.invalidate({ roundId })
utils.mentor.getRoundStats.invalidate({ roundId })
}}
/>
</div>
)
}
const Pill = ({
value,
label,
count,
}: {
value: Filter
label: string
count: number
}) => (
<button
type="button"
onClick={() => setFilter(value)}
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors ${
filter === value
? 'border-primary bg-primary text-primary-foreground'
: 'bg-background hover:bg-muted'
}`}
>
{label}{' '}
<span className="tabular-nums opacity-80">({count})</span>
</button>
)
return (
<div className="space-y-3">
{importBanner}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap gap-1.5">
<Pill value="all" label="All" count={totals.total} />
<Pill value="unassigned" label="No mentor" count={totals.unassigned} />
<Pill value="assigned" label="Has mentor" count={totals.assigned} />
<Pill value="wants_only" label="Wants mentorship" count={totals.wants} />
</div>
<div className="flex items-center gap-2">
<div className="relative w-full sm:w-72">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search projects, teams, or mentors…"
className="pl-8"
/>
</div>
<Button
size="sm"
onClick={() => setAddProjectOpen(true)}
className="shrink-0"
>
<Plus className="mr-1 h-4 w-4" />
Add
</Button>
</div>
</div>
{selected.size > 0 ? (
<div className="flex flex-col gap-2 rounded-md border border-primary/30 bg-primary/5 px-4 py-2.5 text-sm sm:flex-row sm:items-center sm:justify-between">
<div>
<span className="font-medium">{selected.size}</span>{' '}
<span className="text-muted-foreground">
project{selected.size === 1 ? '' : 's'} selected
</span>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => setBulkOpen(true)}>
<UserPlus className="mr-1.5 h-4 w-4" />
Assign mentor
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setSelected(new Set())}
>
<X className="mr-1 h-4 w-4" />
Clear
</Button>
</div>
</div>
) : (
<div className="flex items-center justify-between rounded-md border border-dashed bg-muted/20 px-4 py-2 text-xs text-muted-foreground">
<span>
Tip: tick checkboxes to bulk-assign one mentor to multiple
projects in a single click (mentor gets one combined email).
</span>
{totals.unassigned > 0 && (
<button
type="button"
className="text-xs font-medium text-foreground hover:underline"
onClick={() => {
setFilter('unassigned')
setSelected(
new Set(
data.projects
.filter((p) => p.mentors.length === 0)
.map((p) => p.id),
),
)
}}
>
Select all {totals.unassigned} without a mentor
</button>
)}
</div>
)}
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={
filtered.length > 0 &&
filtered.every((p) => selected.has(p.id))
}
onCheckedChange={(checked) => {
setSelected((prev) => {
const next = new Set(prev)
if (checked) {
filtered.forEach((p) => next.add(p.id))
} else {
filtered.forEach((p) => next.delete(p.id))
}
return next
})
}}
aria-label="Select all visible"
/>
</TableHead>
<TableHead>Project</TableHead>
<TableHead>Wants?</TableHead>
<TableHead>Mentors</TableHead>
<TableHead className="w-32 text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className="py-8 text-center text-sm text-muted-foreground"
>
No projects match the current filter.
</TableCell>
</TableRow>
) : (
filtered.map((p) => (
<TableRow
key={p.id}
data-state={selected.has(p.id) ? 'selected' : undefined}
>
<TableCell>
<Checkbox
checked={selected.has(p.id)}
onCheckedChange={(checked) =>
setSelected((prev) => {
const next = new Set(prev)
if (checked) next.add(p.id)
else next.delete(p.id)
return next
})
}
aria-label={`Select ${p.title}`}
/>
</TableCell>
<TableCell>
<div className="font-medium">{p.title}</div>
<div className="text-xs text-muted-foreground">
{p.teamName ?? '—'}
{p.country && (
<>
{' · '}
<CountryDisplay country={p.country} />
</>
)}
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
{p.wantsMentorship ? (
<Badge variant="secondary" className="w-fit text-xs">
Requested
</Badge>
) : (
<span className="text-xs text-muted-foreground">No</span>
)}
{p.finalistConfirmationStatus !== 'CONFIRMED' && (
<span
className="text-[10px] uppercase tracking-wide text-amber-700"
title="Auto-fill skips projects whose team has not confirmed attendance."
>
{p.finalistConfirmationStatus
? p.finalistConfirmationStatus.toLowerCase()
: 'no confirmation'}
</span>
)}
</div>
</TableCell>
<TableCell>
{p.mentors.length === 0 ? (
<span className="text-xs italic text-muted-foreground">
Unassigned
</span>
) : (
<div className="flex flex-wrap gap-1">
{p.mentors.map((m) => (
<Badge
key={m.assignmentId}
variant="outline"
className="gap-1 text-xs"
title={m.email}
>
{(m.method === 'AI_AUTO' ||
m.method === 'AI_SUGGESTED') && (
<Sparkles className="h-3 w-3 text-amber-500" />
)}
{m.name ?? m.email}
</Badge>
))}
</div>
)}
</TableCell>
<TableCell className="text-right">
<Button asChild size="sm" variant="outline">
<Link href={`/admin/projects/${p.id}/mentor`}>
{p.mentors.length === 0 ? (
<>
<UserPlus className="mr-1 h-3.5 w-3.5" />
Assign
</>
) : (
<>
Open
<ArrowRight className="ml-1 h-3.5 w-3.5" />
</>
)}
</Link>
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<Dialog
open={bulkOpen}
onOpenChange={(next) => {
if (!next) {
setBulkOpen(false)
setChosenMentorIds(new Set())
setMentorSearch('')
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
Assign mentors to {selected.size} project
{selected.size === 1 ? '' : 's'}
</DialogTitle>
<DialogDescription>
Tick any number of mentors. Each chosen mentor will be added to
every selected project they aren&apos;t already on. Each mentor
receives one combined email; each team receives one intro email
listing all of their mentors.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{(() => {
const allMentors = mentorPool?.mentors ?? []
const chosenMentors = allMentors.filter((m) =>
chosenMentorIds.has(m.id),
)
const upperBound = chosenMentorIds.size * selected.size
return (
<>
{chosenMentors.length > 0 && (
<div className="flex flex-wrap gap-1 rounded-md border bg-muted/30 p-2">
{chosenMentors.map((m) => (
<Badge
key={m.id}
variant="secondary"
className="gap-1 pl-2 pr-1"
>
{m.name ?? m.email}
<button
type="button"
aria-label={`Remove ${m.name ?? m.email}`}
className="rounded-full p-0.5 hover:bg-foreground/10"
onClick={() =>
setChosenMentorIds((prev) => {
const next = new Set(prev)
next.delete(m.id)
return next
})
}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={mentorSearch}
onChange={(e) => setMentorSearch(e.target.value)}
placeholder="Search mentor by name, email, country, or expertise…"
className="pl-8"
/>
</div>
<div className="max-h-72 overflow-y-auto rounded-md border">
{(() => {
const q = mentorSearch.trim().toLowerCase()
const filteredMentors = q
? allMentors.filter((m) =>
[
m.name ?? '',
m.email,
m.country ?? '',
...(m.expertiseTags ?? []),
]
.join(' ')
.toLowerCase()
.includes(q),
)
: allMentors
if (allMentors.length === 0) {
return (
<p className="p-4 text-center text-sm text-muted-foreground">
No mentors in the pool yet.{' '}
<Link
href="/admin/members?tab=mentors"
className="underline-offset-2 hover:underline"
>
Add mentors
</Link>
.
</p>
)
}
if (filteredMentors.length === 0) {
return (
<p className="p-4 text-center text-sm text-muted-foreground">
No mentors match &ldquo;{mentorSearch}&rdquo;.
</p>
)
}
return filteredMentors.map((m) => {
const isChosen = chosenMentorIds.has(m.id)
return (
<label
key={m.id}
className={`flex cursor-pointer items-start gap-3 border-b px-3 py-2 text-sm last:border-b-0 ${
isChosen ? 'bg-accent' : 'hover:bg-muted/50'
}`}
>
<Checkbox
className="mt-1"
checked={isChosen}
onCheckedChange={(checked) =>
setChosenMentorIds((prev) => {
const next = new Set(prev)
if (checked) next.add(m.id)
else next.delete(m.id)
return next
})
}
aria-label={`Toggle ${m.name ?? m.email}`}
/>
<div className="min-w-0 flex-1">
<div className="font-medium">
{m.name ?? 'Unnamed'}
</div>
<div className="truncate text-xs text-muted-foreground">
{m.email}
{m.country && <> · {m.country}</>}
</div>
{m.expertiseTags && m.expertiseTags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{m.expertiseTags.slice(0, 4).map((t) => (
<Badge
key={t}
variant="secondary"
className="text-[10px]"
>
{t}
</Badge>
))}
{m.expertiseTags.length > 4 && (
<Badge
variant="outline"
className="text-[10px]"
>
+{m.expertiseTags.length - 4}
</Badge>
)}
</div>
)}
</div>
<div className="shrink-0 text-right text-xs tabular-nums text-muted-foreground">
{m.currentAssignments}
{m.maxAssignments != null && `/${m.maxAssignments}`}{' '}
load
</div>
</label>
)
})
})()}
</div>
{chosenMentorIds.size > 0 && (
<p className="text-xs text-muted-foreground">
Will create up to{' '}
<span className="font-medium tabular-nums text-foreground">
{upperBound}
</span>{' '}
assignment{upperBound === 1 ? '' : 's'} (
{chosenMentorIds.size} mentor
{chosenMentorIds.size === 1 ? '' : 's'} × {selected.size}{' '}
project{selected.size === 1 ? '' : 's'}). Pairs that
already exist are skipped.
</p>
)}
</>
)
})()}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setBulkOpen(false)
setChosenMentorIds(new Set())
setMentorSearch('')
}}
>
Cancel
</Button>
<Button
onClick={() =>
bulkAssignMutation.mutate({
mentorIds: Array.from(chosenMentorIds),
projectIds: Array.from(selected),
})
}
disabled={
chosenMentorIds.size === 0 || bulkAssignMutation.isPending
}
>
{bulkAssignMutation.isPending && (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
)}
Assign {chosenMentorIds.size} mentor
{chosenMentorIds.size === 1 ? '' : 's'} to {selected.size} project
{selected.size === 1 ? '' : 's'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AddProjectDialog
open={addProjectOpen}
onOpenChange={setAddProjectOpen}
roundId={roundId}
competitionId={competitionId}
competitionRounds={competitionRounds}
currentSortOrder={currentSortOrder}
onAssigned={() => {
utils.round.listMentoringProjects.invalidate({ roundId })
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
utils.round.getMentoringImportCandidates.invalidate({ roundId })
utils.mentor.getRoundStats.invalidate({ roundId })
}}
/>
</div>
)
}

View File

@@ -9,6 +9,7 @@ import {
ArrowRight, ArrowRight,
Clock, Clock,
FileText, FileText,
Inbox,
MessageCircle, MessageCircle,
Target, Target,
UserCheck, UserCheck,
@@ -48,6 +49,10 @@ export function MentoringRoundOverview({ roundId }: Props) {
{ refetchInterval: 30_000 }, { refetchInterval: 30_000 },
) )
const { data: pool, isLoading: poolLoading } = trpc.mentor.getMentorPool.useQuery({}) const { data: pool, isLoading: poolLoading } = trpc.mentor.getMentorPool.useQuery({})
const { data: pendingChangeRequests } = trpc.mentor.listChangeRequests.useQuery(
{ status: 'PENDING' },
{ refetchInterval: 30_000 },
)
if (statsLoading || poolLoading) { if (statsLoading || poolLoading) {
return ( return (
@@ -60,6 +65,15 @@ export function MentoringRoundOverview({ roundId }: Props) {
} }
if (!stats || !pool) return null if (!stats || !pool) return null
const pendingCount = pendingChangeRequests?.length ?? 0
// If there's at least one pending request, deep-link directly into the
// first one's project (admins can resolve / view siblings from there).
// Otherwise the card stays static.
const firstPendingProjectId = pendingChangeRequests?.[0]?.project.id ?? null
const changeRequestsHref = firstPendingProjectId
? `/admin/projects/${firstPendingProjectId}/mentor`
: null
const requestedPct = stats.totalProjects const requestedPct = stats.totalProjects
? Math.round((stats.requestedCount / stats.totalProjects) * 100) ? Math.round((stats.requestedCount / stats.totalProjects) * 100)
: 0 : 0
@@ -110,7 +124,7 @@ export function MentoringRoundOverview({ roundId }: Props) {
<p className="text-muted-foreground mt-1 text-xs"> <p className="text-muted-foreground mt-1 text-xs">
{assignedPct}% of round{' '} {assignedPct}% of round{' '}
{stats.awaitingAssignment > 0 && ( {stats.awaitingAssignment > 0 && (
<span className="text-amber-700 dark:text-amber-400"> <span className="text-amber-700">
· {stats.awaitingAssignment} awaiting · {stats.awaitingAssignment} awaiting
</span> </span>
)} )}
@@ -173,6 +187,42 @@ export function MentoringRoundOverview({ roundId }: Props) {
</CardContent> </CardContent>
</Card> </Card>
<Card
className={`md:col-span-2 xl:col-span-4 ${
pendingCount > 0 ? 'border-amber-300' : ''
}`}
>
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-3">
<Inbox
className={`h-5 w-5 ${
pendingCount > 0 ? 'text-amber-600' : 'text-muted-foreground'
}`}
/>
<div>
<div className="text-sm font-medium">Pending change requests</div>
<div className="text-muted-foreground text-xs">
Team members asking admin to swap a mentor
</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="text-2xl font-bold tabular-nums">{pendingCount}</div>
{changeRequestsHref ? (
<Link
href={changeRequestsHref}
className="text-muted-foreground hover:text-foreground inline-flex items-center text-xs"
>
Review
<ArrowRight className="ml-0.5 h-3 w-3" />
</Link>
) : (
<span className="text-muted-foreground text-xs">All clear</span>
)}
</div>
</CardContent>
</Card>
<Card className="md:col-span-2 xl:col-span-4"> <Card className="md:col-span-2 xl:col-span-4">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm">Workspace activity</CardTitle> <CardTitle className="text-sm">Workspace activity</CardTitle>

View File

@@ -290,8 +290,8 @@ export function ProjectStatesTable({ competitionId, roundId, roundStatus, compet
<div className="space-y-4"> <div className="space-y-4">
{/* Finalization hint for closed rounds */} {/* Finalization hint for closed rounds */}
{(roundStatus === 'ROUND_CLOSED' || roundStatus === 'ROUND_ARCHIVED') && ( {(roundStatus === 'ROUND_CLOSED' || roundStatus === 'ROUND_ARCHIVED') && (
<div className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/20 px-4 py-3 text-sm"> <div className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm">
<span className="text-blue-700 dark:text-blue-300"> <span className="text-blue-700">
This round is closed. Use the <strong>Finalization</strong> tab to review proposed outcomes and confirm advancement. This round is closed. Use the <strong>Finalization</strong> tab to review proposed outcomes and confirm advancement.
</span> </span>
</div> </div>
@@ -785,7 +785,7 @@ function QuickAddDialog({
* Create New: form to create a project and assign it directly to the round. * Create New: form to create a project and assign it directly to the round.
* From Pool: search existing projects not yet in this round and assign them. * From Pool: search existing projects not yet in this round and assign them.
*/ */
function AddProjectDialog({ export function AddProjectDialog({
open, open,
onOpenChange, onOpenChange,
roundId, roundId,

View File

@@ -699,7 +699,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
This may take a minute. You can continue working results will appear automatically. This may take a minute. You can continue working results will appear automatically.
</p> </p>
</div> </div>
<div className="h-2 w-48 rounded-full bg-blue-100 dark:bg-blue-900 overflow-hidden"> <div className="h-2 w-48 rounded-full bg-blue-100 overflow-hidden">
<div className="h-full w-full rounded-full bg-blue-500 animate-pulse" /> <div className="h-full w-full rounded-full bg-blue-500 animate-pulse" />
</div> </div>
</> </>
@@ -962,18 +962,18 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
{/* Ranking in-progress banner */} {/* Ranking in-progress banner */}
{rankingInProgress && ( {rankingInProgress && (
<Card className="border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30"> <Card className="border-blue-200 bg-blue-50">
<CardContent className="flex items-center gap-3 py-4"> <CardContent className="flex items-center gap-3 py-4">
<Loader2 className="h-5 w-5 animate-spin text-blue-600 dark:text-blue-400 flex-shrink-0" /> <Loader2 className="h-5 w-5 animate-spin text-blue-600 flex-shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-blue-900 dark:text-blue-200"> <p className="text-sm font-medium text-blue-900">
Ranking in progress&hellip; Ranking in progress&hellip;
</p> </p>
<p className="text-xs text-blue-700 dark:text-blue-400"> <p className="text-xs text-blue-700">
This may take a minute. You can continue working results will appear automatically. This may take a minute. You can continue working results will appear automatically.
</p> </p>
</div> </div>
<div className="h-1.5 w-32 rounded-full bg-blue-200 dark:bg-blue-800 overflow-hidden flex-shrink-0"> <div className="h-1.5 w-32 rounded-full bg-blue-200 overflow-hidden flex-shrink-0">
<div className="h-full w-full rounded-full bg-blue-500 animate-pulse" /> <div className="h-full w-full rounded-full bg-blue-500 animate-pulse" />
</div> </div>
</CardContent> </CardContent>
@@ -1097,7 +1097,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }} exit={{ opacity: 0, y: -20 }}
className={isAdvancing className={isAdvancing
? 'rounded-lg bg-emerald-50 border-l-4 border-emerald-400 dark:bg-emerald-950/20 dark:border-emerald-600' ? 'rounded-lg bg-emerald-50 border-l-4 border-emerald-400'
: ''} : ''}
> >
<SortableProjectRow <SortableProjectRow
@@ -1120,7 +1120,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
{isCutoffRow && ( {isCutoffRow && (
<div className="flex items-center gap-2 py-1"> <div className="flex items-center gap-2 py-1">
<div className="flex-1 border-t-2 border-dashed border-emerald-400/60" /> <div className="flex-1 border-t-2 border-dashed border-emerald-400/60" />
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400 whitespace-nowrap"> <span className="text-xs font-medium text-emerald-600 whitespace-nowrap">
Advancement cutoff {isThresholdMode ? `Score ≥ ${threshold}` : `Top ${advanceCount}`} Advancement cutoff {isThresholdMode ? `Score ≥ ${threshold}` : `Top ${advanceCount}`}
</span> </span>
<div className="flex-1 border-t-2 border-dashed border-emerald-400/60" /> <div className="flex-1 border-t-2 border-dashed border-emerald-400/60" />

View File

@@ -0,0 +1,61 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Mail } from 'lucide-react'
import { EmailPreviewDialog } from './email-preview-dialog'
interface SendMentorshipWelcomeButtonProps {
roundId: string
}
export function SendMentorshipWelcomeButton({ roundId }: SendMentorshipWelcomeButtonProps) {
const [open, setOpen] = useState(false)
const [customMessage, setCustomMessage] = useState<string | undefined>()
const preview = trpc.mentor.previewMentorshipWelcome.useQuery(
{ roundId, customNote: customMessage },
{ enabled: open },
)
const sendMutation = trpc.mentor.sendMentorshipWelcome.useMutation({
onSuccess: (data) => {
toast.success(
`Sent ${data.sent} email${data.sent !== 1 ? 's' : ''}${data.failed ? ` (${data.failed} failed)` : ''}`,
)
setOpen(false)
},
onError: (err) => toast.error(err.message),
})
return (
<>
<button
onClick={() => setOpen(true)}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-sky-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full"
>
<Mail className="h-5 w-5 text-sky-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Send Welcome / Reminder</p>
<p className="text-xs text-muted-foreground mt-0.5">
Email all mentors &amp; teams how to use mentorship
</p>
</div>
</button>
<EmailPreviewDialog
open={open}
onOpenChange={setOpen}
title="Send Mentorship Welcome / Reminder"
description="Emails every mentor and team member in this round with their assignment and how to use the mentorship features. You can add an optional note below."
recipientCount={preview.data?.recipientCount ?? 0}
previewHtml={preview.data?.html}
isPreviewLoading={preview.isLoading}
onSend={(msg) => sendMutation.mutate({ roundId, customNote: msg })}
isSending={sendMutation.isPending}
onRefreshPreview={(msg) => setCustomMessage(msg)}
/>
</>
)
}

View File

@@ -33,19 +33,19 @@ const severityConfig = {
critical: { critical: {
icon: AlertTriangle, icon: AlertTriangle,
iconClass: 'text-red-600', iconClass: 'text-red-600',
bgClass: 'bg-red-50 dark:bg-red-950/30', bgClass: 'bg-red-50',
borderClass: 'border-l-red-500', borderClass: 'border-l-red-500',
}, },
warning: { warning: {
icon: AlertCircle, icon: AlertCircle,
iconClass: 'text-amber-600', iconClass: 'text-amber-600',
bgClass: 'bg-amber-50 dark:bg-amber-950/30', bgClass: 'bg-amber-50',
borderClass: 'border-l-amber-500', borderClass: 'border-l-amber-500',
}, },
info: { info: {
icon: Info, icon: Info,
iconClass: 'text-blue-600', iconClass: 'text-blue-600',
bgClass: 'bg-blue-50 dark:bg-blue-950/30', bgClass: 'bg-blue-50',
borderClass: 'border-l-blue-500', borderClass: 'border-l-blue-500',
}, },
} }
@@ -54,8 +54,8 @@ export function SmartActions({ actions }: SmartActionsProps) {
return ( return (
<Card> <Card>
<CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-4"> <CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-4">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-amber-100 dark:bg-amber-900/40"> <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-amber-100">
<Zap className="h-5 w-5 text-amber-600 dark:text-amber-400" /> <Zap className="h-5 w-5 text-amber-600" />
</div> </div>
<CardTitle className="flex-1">Action Required</CardTitle> <CardTitle className="flex-1">Action Required</CardTitle>
{actions.length > 0 && ( {actions.length > 0 && (
@@ -65,8 +65,8 @@ export function SmartActions({ actions }: SmartActionsProps) {
<CardContent> <CardContent>
{actions.length === 0 ? ( {actions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center"> <div className="flex flex-col items-center justify-center py-8 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/40"> <div className="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100">
<CheckCircle2 className="h-6 w-6 text-emerald-600 dark:text-emerald-400" /> <CheckCircle2 className="h-6 w-6 text-emerald-600" />
</div> </div>
<p className="mt-3 text-sm font-medium text-muted-foreground"> <p className="mt-3 text-sm font-medium text-muted-foreground">
All caught up! All caught up!

View File

@@ -207,7 +207,7 @@ export function EvaluationFormFields({
className={cn( className={cn(
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all', 'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
currentValue === true currentValue === true
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400' ? 'border-emerald-500 bg-emerald-50 text-emerald-700'
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50', : 'border-border hover:border-emerald-300 hover:bg-emerald-50/50',
isReadOnly && 'opacity-60 cursor-default', isReadOnly && 'opacity-60 cursor-default',
)} )}
@@ -222,7 +222,7 @@ export function EvaluationFormFields({
className={cn( className={cn(
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all', 'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
currentValue === false currentValue === false
? 'border-red-500 bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400' ? 'border-red-500 bg-red-50 text-red-700'
: 'border-border hover:border-red-300 hover:bg-red-50/50', : 'border-border hover:border-red-300 hover:bg-red-50/50',
isReadOnly && 'opacity-60 cursor-default', isReadOnly && 'opacity-60 cursor-default',
)} )}

View File

@@ -83,10 +83,10 @@ export function EvaluationFormWithCOI({
<CardContent className="flex items-center gap-3 py-6"> <CardContent className="flex items-center gap-3 py-6">
<ShieldAlert className="h-6 w-6 text-amber-600 shrink-0" /> <ShieldAlert className="h-6 w-6 text-amber-600 shrink-0" />
<div> <div>
<p className="font-medium text-amber-800 dark:text-amber-200"> <p className="font-medium text-amber-800">
Conflict of Interest Declared Conflict of Interest Declared
</p> </p>
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1"> <p className="text-sm text-amber-700 mt-1">
You declared a conflict of interest for this project. An admin will You declared a conflict of interest for this project. An admin will
review your declaration. You cannot evaluate this project while the review your declaration. You cannot evaluate this project while the
conflict is under review. conflict is under review.

View File

@@ -62,7 +62,7 @@ export function JuryPreferencesBanner() {
if (isLoading || unconfirmed.length === 0) return null if (isLoading || unconfirmed.length === 0) return null
return ( return (
<Card className="border-amber-300 bg-amber-50/50 dark:border-amber-800 dark:bg-amber-950/20"> <Card className="border-amber-300 bg-amber-50/50">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Scale className="h-5 w-5 text-amber-600" /> <Scale className="h-5 w-5 text-amber-600" />

View File

@@ -19,11 +19,10 @@ import {
import type { Route } from 'next' import type { Route } from 'next'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import { import {
LogOut, Menu, Moon, Settings, Sun, User, X, LogOut, Menu, Settings, User, X,
ArrowRightLeft, ArrowRightLeft,
ExternalLink as ExternalLinkIcon, HelpCircle, Mail, ExternalLink as ExternalLinkIcon, HelpCircle, Mail,
} from 'lucide-react' } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Logo } from '@/components/shared/logo' import { Logo } from '@/components/shared/logo'
import { NotificationBell } from '@/components/shared/notification-bell' import { NotificationBell } from '@/components/shared/notification-bell'
import { useRoleSwitcher, RoleSwitcherPill } from './role-switcher' import { useRoleSwitcher, RoleSwitcherPill } from './role-switcher'
@@ -69,9 +68,6 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
}) })
const endImpersonation = trpc.user.endImpersonation.useMutation() const endImpersonation = trpc.user.endImpersonation.useMutation()
const logNavClick = trpc.learningResource.logNavClick.useMutation() const logNavClick = trpc.learningResource.logNavClick.useMutation()
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
const handleSignOut = async () => { const handleSignOut = async () => {
if (isImpersonating) { if (isImpersonating) {
@@ -172,20 +168,6 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
{mounted && (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
aria-label="Toggle theme"
>
{theme === 'dark' ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</Button>
)}
<RoleSwitcherPill currentBasePath={basePath} /> <RoleSwitcherPill currentBasePath={basePath} />
<NotificationBell /> <NotificationBell />
<DropdownMenu> <DropdownMenu>

View File

@@ -17,7 +17,7 @@ import { FileText, Upload, CheckCircle2, ArrowUp } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
interface FilePromotionPanelProps { interface FilePromotionPanelProps {
mentorAssignmentId: string projectId: string
} }
function formatFileSize(bytes: number): string { function formatFileSize(bytes: number): string {
@@ -28,14 +28,14 @@ function formatFileSize(bytes: number): string {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
} }
export function FilePromotionPanel({ mentorAssignmentId }: FilePromotionPanelProps) { export function FilePromotionPanel({ projectId }: FilePromotionPanelProps) {
const [selectedSlot, setSelectedSlot] = useState<string>('') const [selectedSlot, setSelectedSlot] = useState<string>('')
const utils = trpc.useUtils() const utils = trpc.useUtils()
const { data: workspaceFiles = [], isLoading: filesLoading } = const { data: workspaceFiles = [], isLoading: filesLoading } =
trpc.mentor.workspaceGetFiles.useQuery( trpc.mentor.workspaceGetFiles.useQuery(
{ mentorAssignmentId }, { projectId },
{ enabled: !!mentorAssignmentId }, { enabled: !!projectId },
) )
const promoteMutation = trpc.mentor.workspacePromoteFile.useMutation({ const promoteMutation = trpc.mentor.workspacePromoteFile.useMutation({

View File

@@ -12,10 +12,18 @@ import {
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { FileText, Upload, Download, Trash2, MessageSquare } from 'lucide-react' import { Eye, FileText, Upload, Download, Trash2, MessageSquare, X, Loader2 } from 'lucide-react'
import { formatDistanceToNow } from 'date-fns' import { formatDistanceToNow } from 'date-fns'
import { FilePreview, isOfficeFile } from '@/components/shared/file-viewer'
interface Props { interface Props {
/** Project the workspace belongs to — drives file list (project-scoped). */
projectId: string
/**
* One MentorAssignment id on this project — needed only to mint upload tokens
* (the token is signed against the assignment + project pair, but the file
* itself is project-scoped so co-mentors see it).
*/
mentorAssignmentId: string mentorAssignmentId: string
/** Set true on the applicant side to label uploads as "Team upload" — purely cosmetic. */ /** Set true on the applicant side to label uploads as "Team upload" — purely cosmetic. */
asApplicant?: boolean asApplicant?: boolean
@@ -29,21 +37,21 @@ function formatSize(bytes: number): string {
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
} }
export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props) { export function WorkspaceFilesPanel({ projectId, mentorAssignmentId, asApplicant }: Props) {
const utils = trpc.useUtils() const utils = trpc.useUtils()
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const { data: files, isLoading } = trpc.mentor.workspaceGetFiles.useQuery( const { data: files, isLoading } = trpc.mentor.workspaceGetFiles.useQuery(
{ mentorAssignmentId }, { projectId },
{ enabled: !!mentorAssignmentId } { enabled: !!projectId }
) )
const presign = trpc.mentor.workspaceGetUploadUrl.useMutation() const presign = trpc.mentor.workspaceGetUploadUrl.useMutation()
const recordUpload = trpc.mentor.workspaceUploadFile.useMutation({ const recordUpload = trpc.mentor.workspaceUploadFile.useMutation({
onSuccess: () => { onSuccess: () => {
utils.mentor.workspaceGetFiles.invalidate({ mentorAssignmentId }) utils.mentor.workspaceGetFiles.invalidate({ projectId })
setDescription('') setDescription('')
toast.success('File uploaded') toast.success('File uploaded')
}, },
@@ -51,7 +59,7 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props)
const downloadMutation = trpc.mentor.workspaceGetFileDownloadUrl.useMutation() const downloadMutation = trpc.mentor.workspaceGetFileDownloadUrl.useMutation()
const deleteMutation = trpc.mentor.workspaceDeleteFile.useMutation({ const deleteMutation = trpc.mentor.workspaceDeleteFile.useMutation({
onSuccess: () => { onSuccess: () => {
utils.mentor.workspaceGetFiles.invalidate({ mentorAssignmentId }) utils.mentor.workspaceGetFiles.invalidate({ projectId })
toast.success('File deleted') toast.success('File deleted')
}, },
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
@@ -83,10 +91,43 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props)
} }
} }
const [previewFileId, setPreviewFileId] = useState<string | null>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const canPreviewMime = (m: string, name: string) =>
m.startsWith('video/') || m === 'application/pdf' || m.startsWith('image/') || isOfficeFile(m, name)
const togglePreview = async (file: { id: string; mimeType: string; fileName: string }) => {
if (previewFileId === file.id) {
setPreviewFileId(null)
setPreviewUrl(null)
return
}
setPreviewFileId(file.id)
setPreviewUrl(null)
setPreviewLoading(true)
try {
const { url } = await downloadMutation.mutateAsync({ mentorFileId: file.id, disposition: 'inline' })
setPreviewUrl(url)
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Preview failed')
setPreviewFileId(null)
} finally {
setPreviewLoading(false)
}
}
const handleDownload = async (mentorFileId: string) => { const handleDownload = async (mentorFileId: string) => {
try { try {
const { url } = await downloadMutation.mutateAsync({ mentorFileId }) const { url } = await downloadMutation.mutateAsync({ mentorFileId, disposition: 'attachment' })
window.open(url, '_blank') const a = document.createElement('a')
a.href = url
a.download = ''
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
a.remove()
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Download failed') toast.error(err instanceof Error ? err.message : 'Download failed')
} }
@@ -141,8 +182,12 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props)
)} )}
<ul className="divide-y"> <ul className="divide-y">
{(files ?? []).map((f) => ( {(files ?? []).map((f) => {
<li key={f.id} className="flex items-center gap-3 py-3"> const isOpen = previewFileId === f.id
const previewable = canPreviewMime(f.mimeType, f.fileName)
return (
<li key={f.id} className="py-3 space-y-2">
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-muted-foreground shrink-0" /> <FileText className="h-5 w-5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium truncate">{f.fileName}</div> <div className="font-medium truncate">{f.fileName}</div>
@@ -160,7 +205,24 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props)
<div className="text-xs text-muted-foreground mt-1">{f.description}</div> <div className="text-xs text-muted-foreground mt-1">{f.description}</div>
)} )}
</div> </div>
<Button variant="ghost" size="icon" onClick={() => handleDownload(f.id)}> {previewable && (
<Button
variant="ghost"
size="icon"
onClick={() => togglePreview(f)}
title={isOpen ? 'Close preview' : 'Preview'}
aria-label={isOpen ? 'Close preview' : 'Preview file'}
>
{isOpen ? <X className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={() => handleDownload(f.id)}
title="Download"
aria-label="Download file"
>
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
</Button> </Button>
<AlertDialog> <AlertDialog>
@@ -184,8 +246,22 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props)
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</div>
{isOpen && (
<div className="rounded-md border bg-muted/30 overflow-hidden">
{previewLoading || !previewUrl ? (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Loading preview
</div>
) : (
<FilePreview file={{ mimeType: f.mimeType, fileName: f.fileName }} url={previewUrl} />
)}
</div>
)}
</li> </li>
))} )
})}
</ul> </ul>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -9,10 +9,10 @@ import { Radio, Users, Trophy, Eye, EyeOff } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const SESSION_STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; pulse?: boolean }> = { const SESSION_STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; pulse?: boolean }> = {
NOT_STARTED: { label: 'Not Started', color: 'text-slate-500', bg: 'bg-slate-100 dark:bg-slate-800' }, NOT_STARTED: { label: 'Not Started', color: 'text-slate-500', bg: 'bg-slate-100' },
IN_PROGRESS: { label: 'In Progress', color: 'text-emerald-600', bg: 'bg-emerald-50 dark:bg-emerald-900/20', pulse: true }, IN_PROGRESS: { label: 'In Progress', color: 'text-emerald-600', bg: 'bg-emerald-50', pulse: true },
PAUSED: { label: 'Paused', color: 'text-amber-600', bg: 'bg-amber-50 dark:bg-amber-900/20' }, PAUSED: { label: 'Paused', color: 'text-amber-600', bg: 'bg-amber-50' },
COMPLETED: { label: 'Completed', color: 'text-blue-600', bg: 'bg-blue-50 dark:bg-blue-900/20' }, COMPLETED: { label: 'Completed', color: 'text-blue-600', bg: 'bg-blue-50' },
} }
export function LiveFinalPanel({ roundId }: { roundId: string }) { export function LiveFinalPanel({ roundId }: { roundId: string }) {

View File

@@ -52,7 +52,7 @@ export function PreviousRoundSection({ currentRoundId }: { currentRoundId: strin
{!collapsed && ( {!collapsed && (
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Headline Stat */} {/* Headline Stat */}
<div className="flex items-center gap-3 rounded-lg bg-rose-50 dark:bg-rose-950/20 p-4"> <div className="flex items-center gap-3 rounded-lg bg-rose-50 p-4">
<ArrowDown className="h-6 w-6 text-rose-500 shrink-0" /> <ArrowDown className="h-6 w-6 text-rose-500 shrink-0" />
<div> <div>
<p className="text-lg font-semibold"> <p className="text-lg font-semibold">
@@ -85,7 +85,7 @@ export function PreviousRoundSection({ currentRoundId }: { currentRoundId: strin
</div> </div>
<div className="relative h-2.5 rounded-full bg-muted overflow-hidden"> <div className="relative h-2.5 rounded-full bg-muted overflow-hidden">
<div <div
className="absolute inset-y-0 left-0 rounded-full bg-slate-300 dark:bg-slate-600 transition-all" className="absolute inset-y-0 left-0 rounded-full bg-slate-300 transition-all"
style={{ width: `${prevPct}%` }} style={{ width: `${prevPct}%` }}
/> />
<div <div

View File

@@ -37,9 +37,9 @@ type OutcomeFilter = 'ALL' | 'PASSED' | 'FILTERED_OUT' | 'FLAGGED'
function outcomeTextColor(outcome: string): string { function outcomeTextColor(outcome: string): string {
switch (outcome) { switch (outcome) {
case 'PASSED': return 'text-emerald-700 dark:text-emerald-400' case 'PASSED': return 'text-emerald-700'
case 'FILTERED_OUT': return 'text-rose-700 dark:text-rose-400' case 'FILTERED_OUT': return 'text-rose-700'
case 'FLAGGED': return 'text-amber-700 dark:text-amber-400' case 'FLAGGED': return 'text-amber-700'
default: return 'text-primary' default: return 'text-primary'
} }
} }

View File

@@ -339,8 +339,8 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
)} )}
{storageProvider === 'local' && ( {storageProvider === 'local' && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950"> <div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
<p className="text-sm text-amber-800 dark:text-amber-200"> <p className="text-sm text-amber-800">
<strong>Warning:</strong> Local storage is not recommended for production deployments <strong>Warning:</strong> Local storage is not recommended for production deployments
with multiple servers, as files will only be accessible from the server that uploaded them. with multiple servers, as files will only be accessible from the server that uploaded them.
</p> </p>

View File

@@ -62,9 +62,9 @@ function getUrgency(totalMs: number): Urgency {
const urgencyStyles: Record<Urgency, string> = { const urgencyStyles: Record<Urgency, string> = {
expired: 'text-muted-foreground bg-muted', expired: 'text-muted-foreground bg-muted',
critical: 'text-red-700 bg-red-50 border-red-200 dark:text-red-400 dark:bg-red-950/50 dark:border-red-900', critical: 'text-red-700 bg-red-50 border-red-200',
warning: 'text-amber-700 bg-amber-50 border-amber-200 dark:text-amber-400 dark:bg-amber-950/50 dark:border-amber-900', warning: 'text-amber-700 bg-amber-50 border-amber-200',
normal: 'text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/50 dark:border-green-900', normal: 'text-green-700 bg-green-50 border-green-200',
} }
export function CountdownTimer({ deadline, label, className }: CountdownTimerProps) { export function CountdownTimer({ deadline, label, className }: CountdownTimerProps) {

View File

@@ -19,18 +19,18 @@ import { useState } from 'react'
const statusConfig: Record<string, { bg: string; text: string; dot: string }> = { const statusConfig: Record<string, { bg: string; text: string; dot: string }> = {
DRAFT: { DRAFT: {
bg: 'bg-amber-50 dark:bg-amber-950/50', bg: 'bg-amber-50',
text: 'text-amber-700 dark:text-amber-400', text: 'text-amber-700',
dot: 'bg-amber-500', dot: 'bg-amber-500',
}, },
ACTIVE: { ACTIVE: {
bg: 'bg-emerald-50 dark:bg-emerald-950/50', bg: 'bg-emerald-50',
text: 'text-emerald-700 dark:text-emerald-400', text: 'text-emerald-700',
dot: 'bg-emerald-500', dot: 'bg-emerald-500',
}, },
ARCHIVED: { ARCHIVED: {
bg: 'bg-slate-100 dark:bg-slate-800/50', bg: 'bg-slate-100',
text: 'text-slate-600 dark:text-slate-400', text: 'text-slate-600',
dot: 'bg-slate-400', dot: 'bg-slate-400',
}, },
} }
@@ -95,10 +95,10 @@ export function EditionSelector() {
{/* Text */} {/* Text */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100"> <p className="truncate text-sm font-semibold text-slate-900">
{currentEdition ? currentEdition.year : 'Select'} {currentEdition ? currentEdition.year : 'Select'}
</p> </p>
<p className="truncate text-xs text-slate-500 dark:text-slate-400"> <p className="truncate text-xs text-slate-500">
{currentEdition?.status === 'ACTIVE' ? 'Current Edition' : currentEdition?.status?.toLowerCase()} {currentEdition?.status === 'ACTIVE' ? 'Current Edition' : currentEdition?.status?.toLowerCase()}
</p> </p>
</div> </div>
@@ -136,7 +136,7 @@ export function EditionSelector() {
}} }}
className={cn( className={cn(
'group/item flex items-center gap-3 rounded-lg px-2.5 py-2.5 cursor-pointer transition-colors', 'group/item flex items-center gap-3 rounded-lg px-2.5 py-2.5 cursor-pointer transition-colors',
isSelected ? 'bg-slate-100 dark:bg-slate-800' : 'hover:bg-slate-50 dark:hover:bg-slate-800/50' isSelected ? 'bg-slate-100' : 'hover:bg-slate-50'
)} )}
> >
{/* Year badge in dropdown */} {/* Year badge in dropdown */}
@@ -144,19 +144,19 @@ export function EditionSelector() {
'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg font-bold text-sm transition-colors', 'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg font-bold text-sm transition-colors',
isSelected isSelected
? 'bg-brand-blue text-white' ? 'bg-brand-blue text-white'
: 'bg-slate-200 text-slate-600 dark:bg-slate-700 dark:text-slate-300' : 'bg-slate-200 text-slate-600'
)}> )}>
{String(edition.year).slice(-2)} {String(edition.year).slice(-2)}
</div> </div>
{/* Edition info */} {/* Edition info */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100"> <p className="truncate text-sm font-semibold text-slate-900">
{edition.year} {edition.year}
</p> </p>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className={cn('h-1.5 w-1.5 rounded-full', editionStatus.dot)} /> <div className={cn('h-1.5 w-1.5 rounded-full', editionStatus.dot)} />
<span className="text-xs text-slate-500 dark:text-slate-400 capitalize"> <span className="text-xs text-slate-500 capitalize">
{edition.status.toLowerCase()} {edition.status.toLowerCase()}
</span> </span>
</div> </div>

View File

@@ -827,9 +827,9 @@ function RequirementChecklist({ roundId, files }: { roundId: string; files: Proj
className={cn( className={cn(
'flex items-center gap-3 rounded-lg border p-2.5 text-sm', 'flex items-center gap-3 rounded-lg border p-2.5 text-sm',
isFulfilled isFulfilled
? 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950' ? 'border-green-200 bg-green-50'
: req.isRequired : req.isRequired
? 'border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950' ? 'border-red-200 bg-red-50'
: 'border-muted' : 'border-muted'
)} )}
> >

View File

@@ -108,20 +108,20 @@ const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
// Priority styles // Priority styles
const PRIORITY_STYLES = { const PRIORITY_STYLES = {
low: { low: {
iconBg: 'bg-slate-100 dark:bg-slate-800', iconBg: 'bg-slate-100',
iconColor: 'text-slate-500', iconColor: 'text-slate-500',
}, },
normal: { normal: {
iconBg: 'bg-blue-100 dark:bg-blue-900/30', iconBg: 'bg-blue-100',
iconColor: 'text-blue-600 dark:text-blue-400', iconColor: 'text-blue-600',
}, },
high: { high: {
iconBg: 'bg-amber-100 dark:bg-amber-900/30', iconBg: 'bg-amber-100',
iconColor: 'text-amber-600 dark:text-amber-400', iconColor: 'text-amber-600',
}, },
urgent: { urgent: {
iconBg: 'bg-red-100 dark:bg-red-900/30', iconBg: 'bg-red-100',
iconColor: 'text-red-600 dark:text-red-400', iconColor: 'text-red-600',
}, },
} }
@@ -158,7 +158,7 @@ function NotificationItem({
data-notification-id={notification.id} data-notification-id={notification.id}
className={cn( className={cn(
'flex gap-3 p-3 hover:bg-muted/50 transition-colors cursor-pointer', 'flex gap-3 p-3 hover:bg-muted/50 transition-colors cursor-pointer',
!notification.isRead && 'bg-blue-50/50 dark:bg-blue-950/20' !notification.isRead && 'bg-blue-50/50'
)} )}
onClick={onRead} onClick={onRead}
> >

View File

@@ -263,9 +263,9 @@ export function RequirementUploadSlot({
const isFulfilled = !!existingFile const isFulfilled = !!existingFile
const statusColor = isFulfilled const statusColor = isFulfilled
? 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950' ? 'border-green-200 bg-green-50'
: requirement.isRequired : requirement.isRequired
? 'border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950' ? 'border-red-200 bg-red-50'
: 'border-muted' : 'border-muted'
// Build accept string for file input // Build accept string for file input

View File

@@ -4,39 +4,39 @@ import { cn } from '@/lib/utils'
const STATUS_STYLES: Record<string, { variant: BadgeProps['variant']; className?: string }> = { const STATUS_STYLES: Record<string, { variant: BadgeProps['variant']; className?: string }> = {
// Round statuses // Round statuses
DRAFT: { variant: 'secondary' }, DRAFT: { variant: 'secondary' },
ACTIVE: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' }, ACTIVE: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200' },
EVALUATION: { variant: 'default', className: 'bg-violet-500/10 text-violet-700 border-violet-200 dark:text-violet-400' }, EVALUATION: { variant: 'default', className: 'bg-violet-500/10 text-violet-700 border-violet-200' },
CLOSED: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200' }, CLOSED: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200' },
ROUND_DRAFT: { variant: 'secondary' }, ROUND_DRAFT: { variant: 'secondary' },
ROUND_ACTIVE: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' }, ROUND_ACTIVE: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200' },
ROUND_CLOSED: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200' }, ROUND_CLOSED: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200' },
ROUND_ARCHIVED: { variant: 'secondary', className: 'bg-slate-400/10 text-slate-400 border-slate-200' }, ROUND_ARCHIVED: { variant: 'secondary', className: 'bg-slate-400/10 text-slate-400 border-slate-200' },
// Project statuses // Project statuses
SUBMITTED: { variant: 'secondary', className: 'bg-indigo-500/10 text-indigo-700 border-indigo-200 dark:text-indigo-400' }, SUBMITTED: { variant: 'secondary', className: 'bg-indigo-500/10 text-indigo-700 border-indigo-200' },
ELIGIBLE: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' }, ELIGIBLE: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200' },
ASSIGNED: { variant: 'default', className: 'bg-violet-500/10 text-violet-700 border-violet-200 dark:text-violet-400' }, ASSIGNED: { variant: 'default', className: 'bg-violet-500/10 text-violet-700 border-violet-200' },
UNDER_REVIEW: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' }, UNDER_REVIEW: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200' },
SHORTLISTED: { variant: 'default', className: 'bg-amber-500/10 text-amber-700 border-amber-200 dark:text-amber-400' }, SHORTLISTED: { variant: 'default', className: 'bg-amber-500/10 text-amber-700 border-amber-200' },
SEMIFINALIST: { variant: 'default', className: 'bg-amber-500/10 text-amber-700 border-amber-200 dark:text-amber-400' }, SEMIFINALIST: { variant: 'default', className: 'bg-amber-500/10 text-amber-700 border-amber-200' },
FINALIST: { variant: 'default', className: 'bg-orange-500/10 text-orange-700 border-orange-200 dark:text-orange-400' }, FINALIST: { variant: 'default', className: 'bg-orange-500/10 text-orange-700 border-orange-200' },
WINNER: { variant: 'default', className: 'bg-yellow-500/10 text-yellow-800 border-yellow-300 dark:text-yellow-400' }, WINNER: { variant: 'default', className: 'bg-yellow-500/10 text-yellow-800 border-yellow-300' },
REJECTED: { variant: 'destructive' }, REJECTED: { variant: 'destructive' },
WITHDRAWN: { variant: 'secondary' }, WITHDRAWN: { variant: 'secondary' },
// Observer-derived statuses // Observer-derived statuses
NOT_REVIEWED: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200 dark:text-slate-400' }, NOT_REVIEWED: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200' },
REVIEWED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' }, REVIEWED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200' },
// Round state statuses // Round state statuses
PENDING: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200 dark:text-slate-400' }, PENDING: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200' },
IN_PROGRESS: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' }, IN_PROGRESS: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200' },
COMPLETED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' }, COMPLETED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200' },
PASSED: { variant: 'default', className: 'bg-green-500/10 text-green-700 border-green-200 dark:text-green-400' }, PASSED: { variant: 'default', className: 'bg-green-500/10 text-green-700 border-green-200' },
// User statuses // User statuses
NONE: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-500 border-slate-200 dark:text-slate-400' }, NONE: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-500 border-slate-200' },
INVITED: { variant: 'secondary', className: 'bg-sky-500/10 text-sky-700 border-sky-200 dark:text-sky-400' }, INVITED: { variant: 'secondary', className: 'bg-sky-500/10 text-sky-700 border-sky-200' },
INACTIVE: { variant: 'secondary' }, INACTIVE: { variant: 'secondary' },
SUSPENDED: { variant: 'destructive' }, SUSPENDED: { variant: 'destructive' },
} }

View File

@@ -9,11 +9,11 @@ const alertVariants = cva(
variant: { variant: {
default: "bg-background text-foreground", default: "bg-background text-foreground",
destructive: destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", "border-destructive/50 text-destructive [&>svg]:text-destructive",
success: success:
"border-green-500/50 text-green-700 dark:border-green-500 [&>svg]:text-green-600", "border-green-500/50 text-green-700 [&>svg]:text-green-600",
warning: warning:
"border-yellow-500/50 text-yellow-700 dark:border-yellow-500 [&>svg]:text-yellow-600", "border-yellow-500/50 text-yellow-700 [&>svg]:text-yellow-600",
}, },
}, },
defaultVariants: { defaultVariants: {

View File

@@ -15,10 +15,10 @@ const badgeVariants = cva(
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80', 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground', outline: 'text-foreground',
success: success:
'border-transparent bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100', 'border-transparent bg-green-100 text-green-800',
warning: warning:
'border-transparent bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100', 'border-transparent bg-amber-100 text-amber-800',
info: 'border-transparent bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100', info: 'border-transparent bg-blue-100 text-blue-800',
}, },
}, },
defaultVariants: { defaultVariants: {

View File

@@ -34,7 +34,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg', 'fixed left-[50%] top-[50%] z-50 flex max-h-[90vh] w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 overflow-y-auto border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
className className
)} )}
{...props} {...props}

View File

@@ -9,6 +9,12 @@ import { prisma } from '@/lib/prisma'
const DEV_EMAIL_OVERRIDE = process.env.DEV_EMAIL_OVERRIDE || '' const DEV_EMAIL_OVERRIDE = process.env.DEV_EMAIL_OVERRIDE || ''
async function sendEmail(opts: { to: string; subject: string; text: string; html: string }): Promise<void> { async function sendEmail(opts: { to: string; subject: string; text: string; html: string }): Promise<void> {
// Hard guard: never send real email from the test runner. This is a belt-and-
// braces check on top of the vitest-level mock in tests/setup.ts. Vitest sets
// NODE_ENV='test' and exposes VITEST=true automatically.
if (process.env.NODE_ENV === 'test' || process.env.VITEST === 'true') {
return
}
const { transporter, from } = await getTransporter() const { transporter, from } = await getTransporter()
const to = DEV_EMAIL_OVERRIDE || opts.to const to = DEV_EMAIL_OVERRIDE || opts.to
const subject = DEV_EMAIL_OVERRIDE ? `[DEV → ${opts.to}] ${opts.subject}` : opts.subject const subject = DEV_EMAIL_OVERRIDE ? `[DEV → ${opts.to}] ${opts.subject}` : opts.subject
@@ -2752,6 +2758,432 @@ export async function sendMentorOnboardingEmail(email: string, name: string | nu
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html }) await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
} }
// =============================================================================
// Per-team mentor assignment (fires every time a mentor is added to a project)
// =============================================================================
function getMentorTeamAssignmentTemplate(
name: string,
projectTitle: string,
workspaceUrl: string,
): EmailTemplate {
const subject = `You've been assigned to a new MOPC project: "${projectTitle}"`
const greeting = name ? `Hi ${name},` : 'Hi there,'
const text = [
greeting,
'',
`You have been assigned as a mentor to the project "${projectTitle}".`,
'',
'You may have co-mentors on this team — you can collaborate together in the project workspace.',
'',
`Open the workspace: ${workspaceUrl}`,
'',
'The MOPC team',
].join('\n')
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;">New mentor assignment</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>
<p>You have been assigned as a mentor to the project <strong>${escapeHtml(projectTitle)}</strong>.</p>
<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 Project Workspace</a>
</p>
<p style="margin-top:24px;color:#64748b;font-size:12px;">
You may have co-mentors on this team — you can collaborate together in the project workspace.
</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()
return { subject, text, html }
}
/**
* Send a per-team mentor assignment email. Fires every time a mentor is added
* to a specific project (distinct from the one-time onboarding email).
* Idempotency is enforced at the call site via MentorAssignment.notificationSentAt.
* Never throws — failures are caught and logged.
*/
export async function sendMentorTeamAssignmentEmail(
email: string,
name: string | null,
projectTitle: string,
projectId: string,
): Promise<void> {
try {
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
const workspaceUrl = `${baseUrl.replace(/\/$/, '')}/mentor/workspace/${projectId}`
const template = getMentorTeamAssignmentTemplate(name || '', projectTitle, workspaceUrl)
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
} catch (error) {
console.error('[sendMentorTeamAssignmentEmail] failed', { email, projectId, error })
}
}
export function getTeamMentorIntroductionTemplate(
recipientName: string | null,
projectTitle: string,
mentors: { name: string | null; email: string }[],
workspaceUrl: string,
teammates?: { name: string | null; email: string }[],
customNote?: string,
): EmailTemplate {
const count = mentors.length
const subject =
count === 1
? `Your mentor for "${projectTitle}" on MOPC`
: `Your ${count} mentors for "${projectTitle}" on MOPC`
const greeting = recipientName ? `Hi ${recipientName},` : 'Hi there,'
const mentorTextLines = mentors
.map((m) => `${m.name ?? 'Mentor'}${m.email}`)
.join('\n')
const teammateTextLines =
teammates && teammates.length > 0
? ['', 'Your team:', ...teammates.map((t) => `${t.name ?? 'Team member'}${t.email}`)]
: []
const text = [
greeting,
'',
...(customNote ? [customNote, ''] : []),
count === 1
? `The mentoring round is now open, and your project "${projectTitle}" has a mentor:`
: `The mentoring round is now open, and your project "${projectTitle}" has ${count} mentors:`,
'',
mentorTextLines,
...teammateTextLines,
'',
'Working with your mentor:',
' - Go to the Mentoring section of your applicant portal to message your mentor directly — they are notified by email when you write.',
' - Share documents and questions early; your mentor is here to help you sharpen your project before the finals.',
' - You can also email your mentor directly using the address above.',
'',
`Open your mentoring page: ${workspaceUrl}`,
'',
'The MOPC team',
].join('\n')
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>
</tr>`,
)
.join('')
const teammatesHtml =
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
.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>
</tr>`,
)
.join('')}</table>`
: ''
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 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>:`,
)}
<table role="presentation" width="100%" 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 }
}
/**
* Introduce a project team to their assigned mentor(s), with each mentor's
* name + email so the team can reach out directly. Sent when the MENTORING
* round opens AND any time a mentor is added to a project whose mentoring
* round is already open. Never throws.
*/
export async function sendTeamMentorIntroductionEmail(
recipientEmail: string,
recipientName: string | null,
projectTitle: string,
projectId: string,
mentors: { name: string | null; email: string }[],
teammates?: { name: string | null; email: string }[],
customNote?: string,
): Promise<boolean> {
try {
if (mentors.length === 0) return false
const baseUrl = getBaseUrl()
const workspaceUrl = `${baseUrl}/applicant/mentor`
const template = getTeamMentorIntroductionTemplate(
recipientName,
projectTitle,
mentors,
workspaceUrl,
teammates,
customNote,
)
await sendEmail({
to: recipientEmail,
subject: template.subject,
text: template.text,
html: template.html,
})
return true
} catch (error) {
console.error('[sendTeamMentorIntroductionEmail] failed', { recipientEmail, projectId, error })
return false
}
}
export function getMentorBulkAssignmentTemplate(
name: string,
projects: {
title: string
url: string
teamMembers?: { name: string | null; email: string }[]
}[],
mentorDashboardUrl: string,
customNote?: string,
): EmailTemplate {
const count = projects.length
const subject =
count === 1
? `You've been assigned to a new MOPC project: "${projects[0].title}"`
: `You've been assigned to ${count} new MOPC projects`
const greeting = name ? `Hi ${name},` : 'Hi there,'
const textBlocks = projects.map((p) => {
const members =
p.teamMembers && p.teamMembers.length > 0
? '\n' +
p.teamMembers
.map((m) => ` - ${m.name ?? 'Team member'}: ${m.email}`)
.join('\n')
: ''
return `${p.title}${p.url}${members}`
})
const text = [
greeting,
'',
...(customNote ? [customNote, ''] : []),
count === 1
? `You have been assigned as a mentor to a new project:`
: `You have been assigned as a mentor to ${count} new projects:`,
'',
...textBlocks,
'',
'How to mentor on MOPC:',
' - Open each project workspace from your Mentor Dashboard to chat with the team, share files, and track milestones.',
' - Messages you send in the workspace notify the team by email automatically.',
' - You can also email team members directly using the addresses listed above.',
'',
`Open your mentor dashboard: ${mentorDashboardUrl}`,
'',
'The MOPC team',
].join('\n')
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
.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>`,
)
.join('')}</table>`
: ''
return `<li style="margin:10px 0;"><a href="${p.url}" style="color:#053d57;text-decoration:none;font-weight:600;">${escapeHtml(p.title)}</a>${members}</li>`
})
.join('')
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 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 }
}
/**
* Send a coalesced mentor-assignment email when one mentor receives multiple
* project assignments in a single bulk operation. Caller passes the list of
* NEW assignments (already filtered to exclude any whose notificationSentAt
* was previously set). Never throws.
*/
export async function sendMentorBulkAssignmentEmail(
email: string,
name: string | null,
projects: { id: string; title: string; teamMembers?: { name: string | null; email: string }[] }[],
customNote?: string,
): Promise<boolean> {
try {
if (projects.length === 0) return false
const baseUrl = getBaseUrl()
const enriched = projects.map((p) => ({
title: p.title,
url: `${baseUrl}/mentor/workspace/${p.id}`,
teamMembers: p.teamMembers,
}))
const template = getMentorBulkAssignmentTemplate(name || '', enriched, `${baseUrl}/mentor`, customNote)
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
return true
} catch (error) {
console.error('[sendMentorBulkAssignmentEmail] failed', { email, error })
return false
}
}
// =============================================================================
// Mentor change requests (PR 8) — admin notification when an applicant or admin
// opens a MentorChangeRequest. Mentors are NOT notified (per design decision).
// =============================================================================
function getMentorChangeRequestTemplate(
projectTitle: string,
requesterName: string | null,
reason: string,
adminDashboardUrl: string,
): EmailTemplate {
const subject = `Mentor change request for "${projectTitle}"`
const requesterLabel = requesterName || 'a team member'
const text = [
'Hi MOPC admins,',
'',
`A mentor change request has been opened by ${requesterLabel} for the project "${projectTitle}".`,
'',
'Reason:',
`"${reason}"`,
'',
`Review the request: ${adminDashboardUrl}`,
'',
'The MOPC team',
].join('\n')
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;">Mentor change request</h1>
</div>
<div style="padding:24px 28px;line-height:1.5;font-size:14px;">
<p style="margin-top:0;">Hi MOPC admins,</p>
<p>A mentor change request has been opened by <strong>${escapeHtml(requesterLabel)}</strong> for the project <strong>${escapeHtml(projectTitle)}</strong>.</p>
<blockquote style="margin:16px 0;padding:12px 16px;background:#f1f5f9;border-left:3px solid #557f8c;border-radius:4px;color:#0f172a;font-style:italic;white-space:pre-wrap;">${escapeHtml(reason)}</blockquote>
<p style="margin-top:24px;">
<a href="${adminDashboardUrl}" style="display:inline-block;padding:10px 20px;background:#de0f1e;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;">Review Request</a>
</p>
<p style="margin-top:24px;color:#64748b;font-size:12px;">
Mentors are not notified of change requests; only admins see this.
</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()
return { subject, text, html }
}
/**
* Notify all SUPER_ADMIN / PROGRAM_ADMIN users that a mentor change request
* has been opened for a project. Sends one email per recipient.
* Never throws — failures are caught and logged so the calling mutation
* (mentor.requestChange) never fails because of email infrastructure issues.
*/
export async function sendMentorChangeRequestEmail(
adminEmails: string[],
projectTitle: string,
requesterName: string | null,
reason: string,
adminDashboardUrl: string,
): Promise<void> {
try {
if (adminEmails.length === 0) {
console.warn('[sendMentorChangeRequestEmail] no admin recipients; skipping')
return
}
const template = getMentorChangeRequestTemplate(
projectTitle,
requesterName,
reason,
adminDashboardUrl,
)
await Promise.all(
adminEmails.map((email) =>
sendEmail({
to: email,
subject: template.subject,
text: template.text,
html: template.html,
}).catch((err) => {
console.error('[sendMentorChangeRequestEmail] send failed', { email, err })
}),
),
)
} catch (error) {
console.error('[sendMentorChangeRequestEmail] failed', { error })
}
}
function getFinalistConfirmationTemplate( function getFinalistConfirmationTemplate(
name: string, name: string,
projectTitle: string, projectTitle: string,

View File

@@ -2,6 +2,13 @@ import { createHmac, timingSafeEqual } from 'crypto'
export type MentorUploadPayload = { export type MentorUploadPayload = {
mentorAssignmentId: string mentorAssignmentId: string
/**
* Project the upload belongs to. Bound at token-issue time so the file's
* project scope can't be tampered with separately from the assignment id.
* Required (no legacy fallback) — tokens live <1h, so any in-flight tokens
* issued before this field was added expire on their own.
*/
projectId: string
uploaderUserId: string uploaderUserId: string
fileName: string fileName: string
mimeType: string mimeType: string
@@ -47,5 +54,8 @@ export function verifyMentorUploadToken(token: string): MentorUploadPayload {
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) { if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) {
throw new Error('Invalid mentor upload token: expired') throw new Error('Invalid mentor upload token: expired')
} }
if (typeof payload.projectId !== 'string' || payload.projectId.length === 0) {
throw new Error('Invalid mentor upload token: missing projectId')
}
return payload return payload
} }

View File

@@ -78,13 +78,17 @@ export async function getPresignedUrl(
objectKey: string, objectKey: string,
method: 'GET' | 'PUT' = 'GET', method: 'GET' | 'PUT' = 'GET',
expirySeconds: number = 900, // 15 minutes default expirySeconds: number = 900, // 15 minutes default
options?: { downloadFileName?: string } options?: { downloadFileName?: string; inline?: boolean; contentType?: string }
): Promise<string> { ): Promise<string> {
const publicClient = getPublicMinioClient() const publicClient = getPublicMinioClient()
if (method === 'GET') { if (method === 'GET') {
const respHeaders = options?.downloadFileName let respHeaders: Record<string, string> | undefined
? { 'response-content-disposition': `attachment; filename="${options.downloadFileName}"` } if (options?.inline) {
: undefined respHeaders = { 'response-content-disposition': 'inline' }
if (options.contentType) respHeaders['response-content-type'] = options.contentType
} else if (options?.downloadFileName) {
respHeaders = { 'response-content-disposition': `attachment; filename="${options.downloadFileName}"` }
}
return publicClient.presignedGetObject(bucket, objectKey, expirySeconds, respHeaders) return publicClient.presignedGetObject(bucket, objectKey, expirySeconds, respHeaders)
} else { } else {
return publicClient.presignedPutObject(bucket, objectKey, expirySeconds) return publicClient.presignedPutObject(bucket, objectKey, expirySeconds)

View File

@@ -2403,4 +2403,67 @@ export const analyticsRouter = router({
prisma: ctx.prisma, prisma: ctx.prisma,
}) })
}), }),
/**
* Nationality breakdown for the applicants (team members) of projects in
* the selected scope. Counts UNIQUE users so a single applicant on
* multiple teams isn't double-counted.
*
* Scope:
* - roundId set → projects with a ProjectRoundState in that round
* - programId set → projects in that program
* - neither → all team members across all projects (global)
*/
getApplicantNationalities: adminProcedure
.input(
z
.object({
roundId: z.string().optional(),
programId: z.string().optional(),
})
.optional()
)
.query(async ({ ctx, input }) => {
const roundId = input?.roundId
const programId = input?.programId
const projectFilter = roundId
? { projectRoundStates: { some: { roundId } } }
: programId
? { programId }
: {}
// Pull all distinct team-member userIds + their nationality in one query.
// `distinct: ['userId']` collapses a user appearing on multiple teams in
// the same scope to a single row.
const teamMembers = await ctx.prisma.teamMember.findMany({
where: { project: projectFilter },
select: { userId: true, user: { select: { nationality: true } } },
distinct: ['userId'],
})
const total = teamMembers.length
const declaredEntries = teamMembers.filter(
(tm) => tm.user?.nationality && tm.user.nationality.trim().length > 0
)
const declared = declaredEntries.length
const notDeclared = total - declared
const counts = new Map<string, number>()
for (const tm of declaredEntries) {
const code = (tm.user!.nationality as string).trim()
counts.set(code, (counts.get(code) ?? 0) + 1)
}
const byCountry = Array.from(counts.entries())
.map(([country, count]) => ({ country, count }))
.sort((a, b) => b.count - a.count || a.country.localeCompare(b.country))
return {
total,
declared,
notDeclared,
byCountry,
}
}),
}) })

View File

@@ -1176,7 +1176,7 @@ export const applicantRouter = router({
], ],
}, },
include: { include: {
mentorAssignment: { select: { mentorId: true } }, mentorAssignments: { select: { mentorId: true } },
}, },
}) })
@@ -1187,7 +1187,10 @@ export const applicantRouter = router({
}) })
} }
if (!project.mentorAssignment) { // TODO(PR8 Task 7): notify ALL assigned mentors. For now we notify the
// first one for legacy parity.
const primaryMentorAssignment = project.mentorAssignments[0] ?? null
if (!primaryMentorAssignment) {
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'No mentor assigned to this project', message: 'No mentor assigned to this project',
@@ -1207,9 +1210,9 @@ export const applicantRouter = router({
}, },
}) })
// Notify the mentor // Notify the (primary) mentor
await createNotification({ await createNotification({
userId: project.mentorAssignment.mentorId, userId: primaryMentorAssignment.mentorId,
type: 'MENTOR_MESSAGE', type: 'MENTOR_MESSAGE',
title: 'New Message', title: 'New Message',
message: `${ctx.user.name || 'Team member'} sent a message about "${project.title}"`, message: `${ctx.user.name || 'Team member'} sent a message about "${project.title}"`,
@@ -1313,12 +1316,13 @@ export const applicantRouter = router({
submittedBy: { submittedBy: {
select: { id: true, name: true, email: true }, select: { id: true, name: true, email: true },
}, },
mentorAssignment: { mentorAssignments: {
include: { include: {
mentor: { mentor: {
select: { id: true, name: true, email: true }, select: { id: true, name: true, email: true, expertiseTags: true },
}, },
}, },
orderBy: { assignedAt: 'asc' },
}, },
wonAwards: { wonAwards: {
select: { id: true, name: true }, select: { id: true, name: true },
@@ -1489,6 +1493,17 @@ export const applicantRouter = router({
logoUrl = await provider.getDownloadUrl(project.logoKey) logoUrl = await provider.getDownloadUrl(project.logoKey)
} }
// Does this user have an open mentor-change request for this project?
// (Used by the applicant mentor page to disable the "Request a change" button.)
const myPendingChangeRequest = await ctx.prisma.mentorChangeRequest.findFirst({
where: {
projectId: project.id,
requestedByUserId: ctx.user.id,
status: 'PENDING',
},
select: { id: true },
})
return { return {
project: { project: {
...project, ...project,
@@ -1502,6 +1517,7 @@ export const applicantRouter = router({
hasPassedIntake: !!passedIntake, hasPassedIntake: !!passedIntake,
isIntakeOpen: !!activeIntakeRound, isIntakeOpen: !!activeIntakeRound,
logoUrl, logoUrl,
hasPendingMentorChangeRequest: !!myPendingChangeRequest,
} }
}), }),
@@ -1523,7 +1539,7 @@ export const applicantRouter = router({
select: { select: {
id: true, id: true,
programId: true, programId: true,
mentorAssignment: { select: { id: true } }, mentorAssignments: { select: { id: true }, take: 1 },
}, },
}) })
@@ -1531,8 +1547,8 @@ export const applicantRouter = router({
return { hasMentor: false, hasEvaluationRounds: false } return { hasMentor: false, hasEvaluationRounds: false }
} }
// Check if mentor is assigned // Check if mentor is assigned (any active assignment counts)
const hasMentor = !!project.mentorAssignment const hasMentor = project.mentorAssignments.length > 0
// Check if feedback is available — first check admin settings, then fall back to per-round config // Check if feedback is available — first check admin settings, then fall back to per-round config
let hasEvaluationRounds = false let hasEvaluationRounds = false
@@ -2689,8 +2705,12 @@ export const applicantRouter = router({
}) })
} }
const assignment = await ctx.prisma.mentorAssignment.findUnique({ // TODO(PR8 Task 7): when multiple mentors are assigned, surface them all
// in the applicant message thread. For now we display the most recently
// assigned (non-dropped) mentor as the "primary".
const assignment = await ctx.prisma.mentorAssignment.findFirst({
where: { projectId: input.projectId }, where: { projectId: input.projectId },
orderBy: { assignedAt: 'desc' },
include: { mentor: { select: { id: true, name: true, email: true } } }, include: { mentor: { select: { id: true, name: true, email: true } } },
}) })

View File

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

View File

@@ -772,7 +772,8 @@ export const finalistRouter = router({
select: { select: {
id: true, id: true,
title: true, title: true,
mentorAssignment: { mentorAssignments: {
where: { droppedAt: null, completionStatus: { not: 'completed' } },
select: { select: {
id: true, id: true,
completionStatus: true, completionStatus: true,
@@ -796,10 +797,12 @@ export const finalistRouter = router({
data: { status: 'SUPERSEDED' }, data: { status: 'SUPERSEDED' },
}) })
// Cascade: drop active mentor assignment (skip if completed or already dropped) // Cascade: drop ALL active mentor assignments (skip dropped/completed
const ma = confirmation.project.mentorAssignment // those were filtered out by the include `where` above). With multi-mentor
// (PR8) we propagate the cascade to every active assignment.
const activeAssignments = confirmation.project.mentorAssignments
let cascadedMentorAssignment = false let cascadedMentorAssignment = false
if (ma && !ma.droppedAt && ma.completionStatus !== 'completed') { for (const ma of activeAssignments) {
await ctx.prisma.mentorAssignment.update({ await ctx.prisma.mentorAssignment.update({
where: { id: ma.id }, where: { id: ma.id },
data: { data: {
@@ -833,6 +836,7 @@ export const finalistRouter = router({
reason: input.reason, reason: input.reason,
projectId: confirmation.projectId, projectId: confirmation.projectId,
cascadedMentorAssignment, cascadedMentorAssignment,
cascadedAssignmentCount: activeAssignments.length,
}, },
}) })
return { ok: true, cascadedMentorAssignment } return { ok: true, cascadedMentorAssignment }

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -188,7 +188,7 @@ export const projectRouter = router({
orClauses.push({ assignments: { some: { userId: ctx.user.id } } }) orClauses.push({ assignments: { some: { userId: ctx.user.id } } })
} }
if (userHasRole(ctx.user, 'MENTOR')) { if (userHasRole(ctx.user, 'MENTOR')) {
orClauses.push({ mentorAssignment: { mentorId: ctx.user.id } }) orClauses.push({ mentorAssignments: { some: { mentorId: ctx.user.id } } })
} }
if (userHasRole(ctx.user, 'APPLICANT')) { if (userHasRole(ctx.user, 'APPLICANT')) {
orClauses.push({ teamMembers: { some: { userId: ctx.user.id } } }) orClauses.push({ teamMembers: { some: { userId: ctx.user.id } } })
@@ -511,7 +511,7 @@ export const projectRouter = router({
}, },
orderBy: { joinedAt: 'asc' }, orderBy: { joinedAt: 'asc' },
}, },
mentorAssignment: { mentorAssignments: {
include: { include: {
mentor: { mentor: {
select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true }, select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true },
@@ -585,14 +585,18 @@ export const projectRouter = router({
})) }))
) )
const mentorWithAvatar = project.mentorAssignment // TODO(PR8 Task 8): surface all mentors. For now we keep the legacy
// single-mentor shape and just pick the first non-dropped assignment
// so the admin UI keeps rendering without changes.
const primaryAssignment = project.mentorAssignments[0] ?? null
const mentorWithAvatar = primaryAssignment
? { ? {
...project.mentorAssignment, ...primaryAssignment,
mentor: { mentor: {
...project.mentorAssignment.mentor, ...primaryAssignment.mentor,
avatarUrl: await getUserAvatarUrl( avatarUrl: await getUserAvatarUrl(
project.mentorAssignment.mentor.profileImageKey, primaryAssignment.mentor.profileImageKey,
project.mentorAssignment.mentor.profileImageProvider primaryAssignment.mentor.profileImageProvider
), ),
}, },
} }
@@ -710,6 +714,7 @@ export const projectRouter = router({
email: member.email.toLowerCase(), email: member.email.toLowerCase(),
name: member.name, name: member.name,
role: 'APPLICANT', role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'NONE', status: 'NONE',
phoneNumber: member.phone || null, phoneNumber: member.phone || null,
}, },
@@ -1311,7 +1316,7 @@ export const projectRouter = router({
}, },
orderBy: { joinedAt: 'asc' }, orderBy: { joinedAt: 'asc' },
}, },
mentorAssignment: { mentorAssignments: {
include: { include: {
mentor: { mentor: {
select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true }, select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true },
@@ -1448,18 +1453,21 @@ export const projectRouter = router({
} }
}) })
), ),
projectRaw.mentorAssignment // TODO(PR8 Task 8): surface all mentors. Legacy shape — pick the first.
? (async () => ({ (async () => {
...projectRaw.mentorAssignment!, const primaryMa = projectRaw.mentorAssignments[0] ?? null
if (!primaryMa) return null
return {
...primaryMa,
mentor: { mentor: {
...projectRaw.mentorAssignment!.mentor, ...primaryMa.mentor,
avatarUrl: await getUserAvatarUrl( avatarUrl: await getUserAvatarUrl(
projectRaw.mentorAssignment!.mentor.profileImageKey, primaryMa.mentor.profileImageKey,
projectRaw.mentorAssignment!.mentor.profileImageProvider primaryMa.mentor.profileImageProvider
), ),
}, },
}))() }
: Promise.resolve(null), })(),
]) ])
return { return {

View File

@@ -227,21 +227,169 @@ export const roundRouter = router({
where: { id: input.roundId }, where: { id: input.roundId },
select: { roundType: true, configJson: true }, select: { roundType: true, configJson: true },
}) })
if (round.roundType !== 'MENTORING') return { count: 0 } if (round.roundType !== 'MENTORING') {
return { count: 0, eligibleTotal: 0, mentorPoolSize: 0 }
}
const config = (round.configJson ?? {}) as Record<string, unknown> const config = (round.configJson ?? {}) as Record<string, unknown>
const eligibility = (config.eligibility as string) ?? 'requested_only' const eligibility = (config.eligibility as string) ?? 'requested_only'
if (eligibility === 'admin_selected') return { count: 0 } if (eligibility === 'admin_selected') {
return { count: 0, eligibleTotal: 0, mentorPoolSize: 0 }
}
const count = await ctx.prisma.projectRoundState.count({ const eligibilityWhere =
eligibility === 'requested_only' ? { wantsMentorship: true } : {}
// Mirror autoAssignBulkForRound's filter exactly so the toolbar count
// matches what the auto-fill button will actually process.
const autoFillWhere = {
mentorAssignments: { none: { droppedAt: null } },
finalistConfirmation: { status: 'CONFIRMED' as const },
...eligibilityWhere,
}
const [count, eligibleTotal, mentorPoolSize] = await Promise.all([
ctx.prisma.projectRoundState.count({
where: { roundId: input.roundId, project: autoFillWhere },
}),
ctx.prisma.projectRoundState.count({
where: { where: {
roundId: input.roundId, roundId: input.roundId,
project: { project: {
mentorAssignment: null, finalistConfirmation: { status: 'CONFIRMED' as const },
...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}), ...eligibilityWhere,
}, },
}, },
}),
ctx.prisma.user.count({
where: { roles: { has: 'MENTOR' }, status: { not: 'SUSPENDED' } },
}),
])
return { count, eligibleTotal, mentorPoolSize }
}),
/**
* For a MENTORING round, find the immediately-prior round in the same
* competition and report how many of its PASSED projects are not yet
* present in this round. Drives the "Import from prior round" CTA so
* admins don't have to manually pick projects via the From-Round modal.
*/
getMentoringImportCandidates: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { roundType: true, competitionId: true, sortOrder: true },
}) })
return { count } if (round.roundType !== 'MENTORING') {
return { priorRound: null, pendingCount: 0 }
}
const prior = await ctx.prisma.round.findFirst({
where: {
competitionId: round.competitionId,
sortOrder: { lt: round.sortOrder },
},
orderBy: { sortOrder: 'desc' },
select: { id: true, name: true, status: true },
})
if (!prior) return { priorRound: null, pendingCount: 0 }
if (prior.status !== 'ROUND_ACTIVE' && prior.status !== 'ROUND_CLOSED') {
return {
priorRound: { id: prior.id, name: prior.name, status: prior.status },
pendingCount: 0,
}
}
const existingInTarget = await ctx.prisma.projectRoundState.findMany({
where: { roundId: input.roundId },
select: { projectId: true },
})
const existingIds = new Set(existingInTarget.map((s) => s.projectId))
const passedInPrior = await ctx.prisma.projectRoundState.findMany({
where: { roundId: prior.id, state: 'PASSED' },
select: { projectId: true },
})
const pendingCount = passedInPrior.filter(
(s) => !existingIds.has(s.projectId),
).length
return {
priorRound: { id: prior.id, name: prior.name, status: prior.status },
pendingCount,
}
}),
/**
* List projects in a MENTORING round with their (multi-)mentor assignments.
* Drives the per-team assignment table on the round Projects tab so admins
* can see who is assigned to whom and add/swap mentors per project.
*/
listMentoringProjects: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { roundType: true, configJson: true },
})
if (round.roundType !== 'MENTORING') return { projects: [] }
const config = (round.configJson ?? {}) as Record<string, unknown>
const eligibility = (config.eligibility as string) ?? 'requested_only'
const states = await ctx.prisma.projectRoundState.findMany({
where: { roundId: input.roundId },
select: {
state: true,
project: {
select: {
id: true,
title: true,
teamName: true,
country: true,
wantsMentorship: true,
competitionCategory: true,
finalistConfirmation: { select: { status: true } },
mentorAssignments: {
where: { droppedAt: null },
select: {
id: true,
method: true,
assignedAt: true,
mentor: { select: { id: true, name: true, email: true } },
},
orderBy: { assignedAt: 'asc' },
},
},
},
},
orderBy: [{ project: { title: 'asc' } }],
})
return {
eligibility,
projects: states.map((s) => {
const isEligible =
eligibility === 'all_in_round' ||
eligibility === 'admin_selected' ||
s.project.wantsMentorship
return {
id: s.project.id,
title: s.project.title,
teamName: s.project.teamName,
country: s.project.country,
competitionCategory: s.project.competitionCategory,
wantsMentorship: s.project.wantsMentorship,
finalistConfirmationStatus:
s.project.finalistConfirmation?.status ?? null,
isEligible,
state: s.state,
mentors: s.project.mentorAssignments.map((a) => ({
assignmentId: a.id,
method: a.method,
assignedAt: a.assignedAt,
id: a.mentor.id,
name: a.mentor.name,
email: a.mentor.email,
})),
}
}),
}
}), }),
/** /**

View File

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

View File

@@ -232,19 +232,26 @@ export const userRouter = router({
const skip = (page - 1) * perPage const skip = (page - 1) * perPage
const where: Record<string, unknown> = {} 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) { if (roles && roles.length > 0) {
where.role = { in: roles } and.push({ OR: [{ role: { in: roles } }, { roles: { hasSome: roles } }] })
} else if (role) { } else if (role) {
where.role = role and.push({ OR: [{ role }, { roles: { has: role } }] })
} }
if (status) where.status = status if (status) where.status = status
if (search) { if (search) {
where.OR = [ and.push({
OR: [
{ email: { contains: search, mode: 'insensitive' } }, { email: { contains: search, mode: 'insensitive' } },
{ name: { contains: search, mode: 'insensitive' } }, { name: { contains: search, mode: 'insensitive' } },
] ],
})
} }
if (and.length > 0) where.AND = and
const dir = sortDir ?? 'asc' const dir = sortDir ?? 'asc'
const orderBy: Record<string, string> = sortBy const orderBy: Record<string, string> = sortBy
@@ -373,19 +380,24 @@ export const userRouter = router({
const where: Record<string, unknown> = { const where: Record<string, unknown> = {
status: { in: ['NONE', 'INVITED'] }, 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) { 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) { } else if (input.role) {
where.role = input.role and.push({ OR: [{ role: input.role }, { roles: { has: input.role } }] })
} }
if (input.search) { if (input.search) {
where.OR = [ and.push({
OR: [
{ email: { contains: input.search, mode: 'insensitive' } }, { email: { contains: input.search, mode: 'insensitive' } },
{ name: { 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({ const users = await ctx.prisma.user.findMany({
where, where,
@@ -498,6 +510,7 @@ export const userRouter = router({
const user = await ctx.prisma.user.create({ const user = await ctx.prisma.user.create({
data: { data: {
...input, ...input,
roles: [input.role],
status: 'INVITED', status: 'INVITED',
inviteToken, inviteToken,
inviteTokenExpiresAt: new Date(Date.now() + expiryHours * 60 * 60 * 1000), inviteTokenExpiresAt: new Date(Date.now() + expiryHours * 60 * 60 * 1000),
@@ -799,6 +812,7 @@ export const userRouter = router({
email: u.email.toLowerCase(), email: u.email.toLowerCase(),
name: u.name, name: u.name,
role: u.role, role: u.role,
roles: [u.role],
expertiseTags: u.expertiseTags, expertiseTags: u.expertiseTags,
status: input.sendInvitation ? 'INVITED' : 'NONE', status: input.sendInvitation ? 'INVITED' : 'NONE',
})), })),

View File

@@ -169,6 +169,14 @@ interface CreateNotificationParams {
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
groupKey?: string groupKey?: string
expiresAt?: Date expiresAt?: Date
/**
* When true, the in-app notification still fires but the parallel email
* send (via NotificationEmailSetting) is suppressed. Callers use this when
* the email belongs to a coalesced/deferred flow that will fire later
* (e.g. mentor assignments staged while a MENTORING round is ROUND_DRAFT —
* the round-open hook sends a single combined email instead).
*/
skipEmail?: boolean
} }
/** /**
@@ -189,6 +197,7 @@ export async function createNotification(
metadata, metadata,
groupKey, groupKey,
expiresAt, expiresAt,
skipEmail,
} = params } = params
// Determine icon and priority if not provided // Determine icon and priority if not provided
@@ -241,8 +250,11 @@ export async function createNotification(
}, },
}) })
// Check if we should also send an email // Check if we should also send an email (suppressed when the caller is
// deferring the email to a coalesced flow).
if (!skipEmail) {
await maybeSendEmail(userId, type, title, message, linkUrl, metadata) await maybeSendEmail(userId, type, title, message, linkUrl, metadata)
}
} }
/** /**
@@ -258,6 +270,8 @@ export async function createBulkNotifications(params: {
icon?: string icon?: string
priority?: NotificationPriority priority?: NotificationPriority
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
/** See {@link CreateNotificationParams.skipEmail}. */
skipEmail?: boolean
}): Promise<void> { }): Promise<void> {
const { const {
userIds, userIds,
@@ -269,6 +283,7 @@ export async function createBulkNotifications(params: {
icon, icon,
priority, priority,
metadata, metadata,
skipEmail,
} = params } = params
const finalIcon = icon || NotificationIcons[type] || 'Bell' const finalIcon = icon || NotificationIcons[type] || 'Bell'
@@ -289,6 +304,8 @@ export async function createBulkNotifications(params: {
})), })),
}) })
if (skipEmail) return
// Check email settings once, then send emails only if enabled // Check email settings once, then send emails only if enabled
const emailSetting = await prisma.notificationEmailSetting.findUnique({ const emailSetting = await prisma.notificationEmailSetting.findUnique({
where: { notificationType: type }, where: { notificationType: type },

View File

@@ -152,6 +152,11 @@ export async function markRead(
/** /**
* Record a file upload in a workspace. * Record a file upload in a workspace.
*
* `workspaceId` is the originating MentorAssignment id (kept on the row as an
* audit-trail FK). We derive the project id from that assignment so the file
* is bound to the project — meaning any co-mentor on the project can see/use
* it, and the row survives if this particular assignment is later dropped.
*/ */
export async function uploadFile( export async function uploadFile(
params: { params: {
@@ -180,6 +185,7 @@ export async function uploadFile(
return prisma.mentorFile.create({ return prisma.mentorFile.create({
data: { data: {
projectId: assignment.projectId,
mentorAssignmentId: params.workspaceId, mentorAssignmentId: params.workspaceId,
uploadedByUserId: params.uploadedByUserId, uploadedByUserId: params.uploadedByUserId,
fileName: params.fileName, fileName: params.fileName,
@@ -238,9 +244,6 @@ export async function promoteFile(
try { try {
const file = await prisma.mentorFile.findUnique({ const file = await prisma.mentorFile.findUnique({
where: { id: params.mentorFileId }, where: { id: params.mentorFileId },
include: {
mentorAssignment: { select: { projectId: true } },
},
}) })
if (!file) { if (!file) {
@@ -265,7 +268,7 @@ export async function promoteFile(
// Create promotion event // Create promotion event
await tx.submissionPromotionEvent.create({ await tx.submissionPromotionEvent.create({
data: { data: {
projectId: file.mentorAssignment.projectId, projectId: file.projectId,
roundId: params.roundId, roundId: params.roundId,
slotKey: params.slotKey, slotKey: params.slotKey,
sourceType: 'MENTOR_FILE', sourceType: 'MENTOR_FILE',
@@ -281,7 +284,7 @@ export async function promoteFile(
entityId: params.mentorFileId, entityId: params.mentorFileId,
actorId: params.promotedById, actorId: params.promotedById,
detailsJson: { detailsJson: {
projectId: file.mentorAssignment.projectId, projectId: file.projectId,
roundId: params.roundId, roundId: params.roundId,
slotKey: params.slotKey, slotKey: params.slotKey,
fileName: file.fileName, fileName: file.fileName,
@@ -297,7 +300,7 @@ export async function promoteFile(
entityType: 'MentorFile', entityType: 'MentorFile',
entityId: params.mentorFileId, entityId: params.mentorFileId,
detailsJson: { detailsJson: {
projectId: file.mentorAssignment.projectId, projectId: file.projectId,
slotKey: params.slotKey, slotKey: params.slotKey,
}, },
}) })
@@ -314,14 +317,17 @@ export async function promoteFile(
} }
/** /**
* List files for a workspace, newest first, with comment counts and uploader. * List files for a project, newest first, with comment counts and uploader.
* Project-scoped: every mentor assigned to the project (and every team member)
* sees the same file list, even if some files were uploaded under a now-dropped
* assignment.
*/ */
export async function getFiles( export async function getFiles(
workspaceId: string, projectId: string,
prisma: PrismaClient, prisma: PrismaClient,
) { ) {
return prisma.mentorFile.findMany({ return prisma.mentorFile.findMany({
where: { mentorAssignmentId: workspaceId }, where: { projectId },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { include: {
uploadedBy: { select: { id: true, name: true, email: true } }, uploadedBy: { select: { id: true, name: true, email: true } },
@@ -331,8 +337,10 @@ export async function getFiles(
} }
/** /**
* Delete a file. Caller must be either the uploader OR the assigned mentor. * Delete a file. Caller must be either the uploader, OR any mentor currently
* Removes the MinIO object and the DB row + cascade-deletes comments. * assigned (not dropped) to the file's project, OR a team member of the
* file's project. Removes the MinIO object and the DB row + cascade-deletes
* comments.
*/ */
export async function deleteFile( export async function deleteFile(
params: { mentorFileId: string; userId: string }, params: { mentorFileId: string; userId: string },
@@ -341,13 +349,30 @@ export async function deleteFile(
): Promise<void> { ): Promise<void> {
const file = await prisma.mentorFile.findUnique({ const file = await prisma.mentorFile.findUnique({
where: { id: params.mentorFileId }, where: { id: params.mentorFileId },
include: { mentorAssignment: { select: { mentorId: true } } },
}) })
if (!file) throw new Error('File not found') if (!file) throw new Error('File not found')
const isUploader = file.uploadedByUserId === params.userId const isUploader = file.uploadedByUserId === params.userId
const isMentor = file.mentorAssignment.mentorId === params.userId let isAuthorized = isUploader
if (!isUploader && !isMentor) { if (!isAuthorized) {
throw new Error('Only the uploader or the assigned mentor can delete this file') const mentorAssignment = await prisma.mentorAssignment.findFirst({
where: { projectId: file.projectId, mentorId: params.userId, droppedAt: null },
select: { id: true },
})
if (mentorAssignment) {
isAuthorized = true
}
}
if (!isAuthorized) {
const teamMembership = await prisma.teamMember.findFirst({
where: { projectId: file.projectId, userId: params.userId },
select: { id: true },
})
if (teamMembership) {
isAuthorized = true
}
}
if (!isAuthorized) {
throw new Error('Only the uploader, an assigned mentor, or a team member can delete this file')
} }
try { try {
await removeStorageObject(file.bucket, file.objectKey) await removeStorageObject(file.bucket, file.objectKey)

View File

@@ -16,6 +16,10 @@ import { logAudit } from '@/server/utils/audit'
import { safeValidateRoundConfig } from '@/types/competition-configs' import { safeValidateRoundConfig } from '@/types/competition-configs'
import { expireIntentsForRound } from './assignment-intent' import { expireIntentsForRound } from './assignment-intent'
import { processRoundClose } from './round-finalization' import { processRoundClose } from './round-finalization'
import {
sendMentorBulkAssignmentEmail,
sendTeamMentorIntroductionEmail,
} from '@/lib/email'
// ─── Types ────────────────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────────────────
@@ -211,6 +215,167 @@ export async function activateRound(
} catch (mentoringError) { } catch (mentoringError) {
console.error('[RoundEngine] Mentoring pass-through failed (non-fatal):', mentoringError) console.error('[RoundEngine] Mentoring pass-through failed (non-fatal):', mentoringError)
} }
// Mentor-side coalesced emails on round open. Picks up every assignment
// for projects in this round whose notificationSentAt is null (i.e.
// assignments made while the round was still in draft), groups by
// mentor, and sends a single combined email per mentor listing all
// their projects in this round.
try {
const pendingAssignments = await prisma.mentorAssignment.findMany({
where: {
droppedAt: null,
notificationSentAt: null,
project: { projectRoundStates: { some: { roundId } } },
},
select: {
id: true,
mentorId: true,
mentor: { select: { name: true, email: true } },
project: {
select: {
id: true,
title: true,
teamMembers: { select: { user: { select: { name: true, email: true } } } },
},
},
},
})
const perMentor = new Map<
string,
{
email: string | null
name: string | null
assignmentIds: string[]
projects: { id: string; title: string; teamMembers: { name: string | null; email: string }[] }[]
}
>()
for (const a of pendingAssignments) {
if (!a.mentor?.email) continue
const bucket = perMentor.get(a.mentorId) ?? {
email: a.mentor.email,
name: a.mentor.name,
assignmentIds: [],
projects: [],
}
bucket.assignmentIds.push(a.id)
bucket.projects.push({
id: a.project.id,
title: a.project.title,
teamMembers: a.project.teamMembers
.filter((tm) => tm.user?.email)
.map((tm) => ({ name: tm.user.name, email: tm.user.email })),
})
perMentor.set(a.mentorId, bucket)
}
for (const bucket of perMentor.values()) {
if (bucket.projects.length === 0 || !bucket.email) continue
await sendMentorBulkAssignmentEmail(
bucket.email,
bucket.name,
bucket.projects,
)
await prisma.mentorAssignment.updateMany({
where: { id: { in: bucket.assignmentIds } },
data: { notificationSentAt: new Date() },
})
}
if (perMentor.size > 0) {
console.log(
`[RoundEngine] MENTORING round open: notified ${perMentor.size} mentor(s) about their assignments`,
)
}
} catch (mentorEmailError) {
console.error(
'[RoundEngine] Mentor-side coalesced notification failed (non-fatal):',
mentorEmailError,
)
}
// Introduce teams to their mentors via email when the round opens.
// Idempotent via MentorAssignment.teamIntroducedAt — separate from the
// mentor-side notificationSentAt so the team email fires even when the
// mentor was assigned (and notified) before the round opened.
try {
const projectsToIntroduce = await prisma.project.findMany({
where: {
projectRoundStates: { some: { roundId } },
mentorAssignments: {
some: { droppedAt: null, teamIntroducedAt: null },
},
},
select: {
id: true,
title: true,
mentorAssignments: {
where: { droppedAt: null },
select: {
id: true,
teamIntroducedAt: true,
mentor: { select: { name: true, email: true } },
},
},
teamMembers: {
select: { user: { select: { name: true, email: true } } },
},
submittedByEmail: true,
submittedBy: { select: { name: true } },
},
})
for (const p of projectsToIntroduce) {
const mentors = p.mentorAssignments
.filter((a) => a.mentor?.email)
.map((a) => ({
name: a.mentor.name,
email: a.mentor.email,
}))
if (mentors.length === 0) continue
// Build a unique recipient set: team-member users with emails,
// plus the original submitter (in case they're not on the team yet).
const recipients = new Map<string, { name: string | null }>()
for (const tm of p.teamMembers) {
if (tm.user?.email) {
recipients.set(tm.user.email, { name: tm.user.name })
}
}
if (
p.submittedByEmail &&
!recipients.has(p.submittedByEmail)
) {
recipients.set(p.submittedByEmail, {
name: p.submittedBy?.name ?? null,
})
}
const allMembers = p.teamMembers
.filter((tm) => tm.user?.email)
.map((tm) => ({ name: tm.user.name, email: tm.user.email }))
for (const [email, { name }] of recipients) {
const teammates = allMembers.filter((m) => m.email !== email)
await sendTeamMentorIntroductionEmail(email, name, p.title, p.id, mentors, teammates)
}
// Stamp every mentor-assignment row so re-activation doesn't re-send.
const idsToStamp = p.mentorAssignments
.filter((a) => a.teamIntroducedAt == null)
.map((a) => a.id)
if (idsToStamp.length > 0) {
await prisma.mentorAssignment.updateMany({
where: { id: { in: idsToStamp } },
data: { teamIntroducedAt: new Date() },
})
}
}
if (projectsToIntroduce.length > 0) {
console.log(
`[RoundEngine] MENTORING round open: introduced mentors for ${projectsToIntroduce.length} project(s)`,
)
}
} catch (introError) {
console.error('[RoundEngine] Team-mentor introduction failed (non-fatal):', introError)
}
} }
return { return {

View File

@@ -670,7 +670,7 @@ export async function getMentorSuggestionsForProject(
projectTags: { projectTags: {
include: { tag: true }, include: { tag: true },
}, },
mentorAssignment: true, mentorAssignments: true,
}, },
}) })
@@ -714,7 +714,7 @@ export async function getMentorSuggestionsForProject(
for (const mentor of mentors) { for (const mentor of mentors) {
// Skip if already assigned to this project // Skip if already assigned to this project
if (project.mentorAssignment?.mentorId === mentor.id) { if (project.mentorAssignments.some((ma) => ma.mentorId === mentor.id)) {
continue continue
} }

View File

@@ -1,7 +1,6 @@
import type { Config } from 'tailwindcss' import type { Config } from 'tailwindcss'
const config: Config = { const config: Config = {
darkMode: 'class',
content: [ content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}', './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}', './src/components/**/*.{js,ts,jsx,tsx,mdx}',

View File

@@ -0,0 +1,138 @@
/**
* PR8 — MentorFile schema invariant check
*
* The actual data migration (backfill of MentorFile.projectId from the
* originating MentorAssignment.projectId) was verified against the May 7
* production database dump in Task 2 of PR8. This file is a complementary
* schema-invariant check that runs against the current dev DB:
*
* 1. MentorFile.projectId is now a required column (Prisma validation fails
* when omitted).
* 2. Files are scoped to the project, not to a single MentorAssignment —
* deleting the originating assignment leaves the file in place with
* mentorAssignmentId set to NULL (FK SetNull) and projectId unchanged.
* This is what enables team-wide file visibility across co-mentors.
*/
import { afterAll, describe, expect, it } from 'vitest'
import { prisma } from '../setup'
import {
createTestUser,
createTestProgram,
createTestProject,
cleanupTestData,
uid,
} from '../helpers'
describe('MentorFile scope invariants (PR8 schema)', () => {
const programIds: string[] = []
const userIds: string[] = []
const mentorFileIds: string[] = []
afterAll(async () => {
if (mentorFileIds.length > 0) {
await prisma.mentorFile.deleteMany({ where: { id: { in: mentorFileIds } } })
}
for (const programId of programIds) {
await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } })
await prisma.mentorFile.deleteMany({ where: { project: { programId } } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
it('MentorFile.projectId matches MentorAssignment.projectId when created via the workspace path', async () => {
const program = await createTestProgram({ name: `mfscope-match-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Scope Match' })
const mentor = await createTestUser('MENTOR')
userIds.push(mentor.id)
const assignment = await prisma.mentorAssignment.create({
data: {
projectId: project.id,
mentorId: mentor.id,
method: 'MANUAL',
workspaceEnabled: true,
},
})
const file = await prisma.mentorFile.create({
data: {
projectId: project.id,
mentorAssignmentId: assignment.id,
uploadedByUserId: mentor.id,
fileName: 'invariant.pdf',
mimeType: 'application/pdf',
size: 1024,
bucket: 'mopc-files',
objectKey: `Scope_Match/mentorship/${Date.now()}-invariant.pdf`,
},
})
mentorFileIds.push(file.id)
expect(file.projectId).toBe(assignment.projectId)
})
it('creating a MentorFile without a projectId is rejected by Prisma', async () => {
const program = await createTestProgram({ name: `mfscope-noproj-${uid()}` })
programIds.push(program.id)
const mentor = await createTestUser('MENTOR')
userIds.push(mentor.id)
// `projectId` is required in the schema — Prisma should reject this.
// Cast away the type for the deliberate omission.
await expect(
prisma.mentorFile.create({
data: {
uploadedByUserId: mentor.id,
fileName: 'no-project.pdf',
mimeType: 'application/pdf',
size: 10,
bucket: 'mopc-files',
objectKey: 'orphan/no-project.pdf',
} as unknown as Parameters<typeof prisma.mentorFile.create>[0]['data'],
}),
).rejects.toThrow()
})
it('dropping the originating MentorAssignment leaves the MentorFile in place (SetNull)', async () => {
const program = await createTestProgram({ name: `mfscope-setnull-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'SetNull Project' })
const mentor = await createTestUser('MENTOR')
userIds.push(mentor.id)
const assignment = await prisma.mentorAssignment.create({
data: {
projectId: project.id,
mentorId: mentor.id,
method: 'MANUAL',
workspaceEnabled: true,
},
})
const file = await prisma.mentorFile.create({
data: {
projectId: project.id,
mentorAssignmentId: assignment.id,
uploadedByUserId: mentor.id,
fileName: 'survives-drop.pdf',
mimeType: 'application/pdf',
size: 2048,
bucket: 'mopc-files',
objectKey: `SetNull_Project/mentorship/${Date.now()}-survives.pdf`,
},
})
mentorFileIds.push(file.id)
await prisma.mentorAssignment.delete({ where: { id: assignment.id } })
const after = await prisma.mentorFile.findUnique({ where: { id: file.id } })
expect(after).not.toBeNull()
expect(after?.mentorAssignmentId).toBeNull()
expect(after?.projectId).toBe(project.id)
})
})

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { prisma, createCaller } from '../setup'
import { createTestUser, uid } from '../helpers'
import { userRouter } from '../../src/server/routers/user'
/**
* Regression: the admin Members tabs filter by type (Jury, Mentor, …) must
* include users who hold that type as a SECONDARY role (User.roles[]), not only
* users whose PRIMARY role (User.role) matches. Previously the query filtered on
* `where.role` alone, so multi-role users were missing from the relevant tab.
*/
describe('user.list / listInvitableIds — role filter includes secondary roles', () => {
const token = uid('secrole')
const ids: string[] = []
let adminId = ''
let aId = '' // primary JURY_MEMBER + secondary MENTOR
let bId = '' // primary MENTOR
let cId = '' // JURY only (control)
beforeAll(async () => {
const admin = await createTestUser('SUPER_ADMIN')
adminId = admin.id
ids.push(admin.id)
const a = await prisma.user.create({
data: {
id: uid('u'),
email: `${token}-a@test.local`,
name: `${token} A`,
role: 'JURY_MEMBER',
roles: ['JURY_MEMBER', 'MENTOR'],
status: 'NONE',
},
})
const b = await prisma.user.create({
data: {
id: uid('u'),
email: `${token}-b@test.local`,
name: `${token} B`,
role: 'MENTOR',
roles: ['MENTOR'],
status: 'NONE',
},
})
const c = await prisma.user.create({
data: {
id: uid('u'),
email: `${token}-c@test.local`,
name: `${token} C`,
role: 'JURY_MEMBER',
roles: ['JURY_MEMBER'],
status: 'NONE',
},
})
aId = a.id
bId = b.id
cId = c.id
ids.push(a.id, b.id, c.id)
})
afterAll(async () => {
await prisma.user.deleteMany({ where: { id: { in: ids } } })
})
it('list: the Mentor tab includes a user whose MENTOR role is secondary', async () => {
const caller = createCaller(userRouter, {
id: adminId,
email: 'admin@test.local',
role: 'SUPER_ADMIN',
})
const res = await caller.list({ roles: ['MENTOR'], search: token, perPage: 100 })
const returnedIds = res.users.map((u: { id: string }) => u.id)
expect(returnedIds).toContain(bId) // primary mentor (already worked)
expect(returnedIds).toContain(aId) // secondary mentor (the bug)
expect(returnedIds).not.toContain(cId) // jury-only must NOT appear
})
it('listInvitableIds: the Mentor tab includes a user whose MENTOR role is secondary', async () => {
const caller = createCaller(userRouter, {
id: adminId,
email: 'admin@test.local',
role: 'SUPER_ADMIN',
})
const res = await caller.listInvitableIds({ roles: ['MENTOR'], search: token })
expect(res.userIds).toContain(bId)
expect(res.userIds).toContain(aId)
expect(res.userIds).not.toContain(cId)
})
})

View File

@@ -213,12 +213,12 @@ describe('mentor.autoAssignBulkForRound', () => {
expect(result.assigned).toBe(1) expect(result.assigned).toBe(1)
const requestedAssigned = await prisma.mentorAssignment.findUnique({ const requestedAssigned = await prisma.mentorAssignment.findFirst({
where: { projectId: projWithRequest.id }, where: { projectId: projWithRequest.id },
}) })
expect(requestedAssigned).not.toBeNull() expect(requestedAssigned).not.toBeNull()
const skippedNotAssigned = await prisma.mentorAssignment.findUnique({ const skippedNotAssigned = await prisma.mentorAssignment.findFirst({
where: { projectId: projWithoutRequest.id }, where: { projectId: projWithoutRequest.id },
}) })
expect(skippedNotAssigned).toBeNull() expect(skippedNotAssigned).toBeNull()
@@ -291,7 +291,7 @@ describe('mentor.autoAssignBulkForRound', () => {
expect(result.assigned).toBe(1) expect(result.assigned).toBe(1)
expect(result.skipped).toBe(1) expect(result.skipped).toBe(1)
const stillExisting = await prisma.mentorAssignment.findUnique({ const stillExisting = await prisma.mentorAssignment.findFirst({
where: { projectId: projAlreadyAssigned.id }, where: { projectId: projAlreadyAssigned.id },
}) })
expect(stillExisting?.mentorId).toBe(existingMentor.id) // unchanged expect(stillExisting?.mentorId).toBe(existingMentor.id) // unchanged
@@ -377,17 +377,17 @@ describe('mentor.autoAssignBulkForRound', () => {
expect(result.assigned).toBe(1) expect(result.assigned).toBe(1)
const confirmedAssigned = await prisma.mentorAssignment.findUnique({ const confirmedAssigned = await prisma.mentorAssignment.findFirst({
where: { projectId: projConfirmed.id }, where: { projectId: projConfirmed.id },
}) })
expect(confirmedAssigned).not.toBeNull() expect(confirmedAssigned).not.toBeNull()
const pendingAssigned = await prisma.mentorAssignment.findUnique({ const pendingAssigned = await prisma.mentorAssignment.findFirst({
where: { projectId: projPending.id }, where: { projectId: projPending.id },
}) })
expect(pendingAssigned).toBeNull() expect(pendingAssigned).toBeNull()
const noConfAssigned = await prisma.mentorAssignment.findUnique({ const noConfAssigned = await prisma.mentorAssignment.findFirst({
where: { projectId: projNoConfirmation.id }, where: { projectId: projNoConfirmation.id },
}) })
expect(noConfAssigned).toBeNull() expect(noConfAssigned).toBeNull()

View File

@@ -0,0 +1,228 @@
/**
* Regression: mentor-assignment emails must be deferred while the
* project's MENTORING round is still ROUND_DRAFT. The earlier fix only
* deferred the explicit `sendMentorBulkAssignmentEmail` path; the parallel
* in-app-notification → email path (MENTEE_ASSIGNED, MENTOR_ASSIGNED) kept
* firing immediately, causing duplicate sends both at assign-time AND
* again when activateRound coalesced the same assignments. Verified
* against prod incident 2026-05-26 (Camille Lopez received 9 emails).
*
* These tests assert that:
* - in DRAFT: in-app notifications still create rows, but the styled
* notification email is NOT sent;
* - in ACTIVE: the styled notification email IS sent (legacy behaviour
* preserved when the round is open).
*/
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser,
createTestProgram,
createTestProject,
cleanupTestData,
uid,
} from '../helpers'
import { mentorRouter } from '../../src/server/routers/mentor'
import type { UserRole } from '@prisma/client'
vi.mock('@/lib/email', async () => {
const actual = await vi.importActual<typeof import('@/lib/email')>('@/lib/email')
return {
...actual,
sendStyledNotificationEmail: vi.fn(async () => undefined),
sendMentorTeamAssignmentEmail: vi.fn(async () => undefined),
sendMentorBulkAssignmentEmail: vi.fn(async () => undefined),
sendTeamMentorIntroductionEmail: vi.fn(async () => undefined),
}
})
const email = await import('@/lib/email')
const sendStyledMock = email.sendStyledNotificationEmail as ReturnType<typeof vi.fn>
const sendMentorBulkMock = email.sendMentorBulkAssignmentEmail as ReturnType<typeof vi.fn>
const sendTeamIntroMock = email.sendTeamMentorIntroductionEmail as ReturnType<typeof vi.fn>
async function makeMentor(): Promise<{ id: string; email: string }> {
const id = uid('mentor')
const u = await prisma.user.create({
data: {
id,
email: `${id}@test.local`,
name: `Mentor ${id}`,
role: 'MENTOR' as UserRole,
roles: ['MENTOR'] as UserRole[],
status: 'ACTIVE',
// Email path requires the user to opt into emails. Default for new test
// users is EMAIL so styled-email sends fire when the gate is open.
notificationPreference: 'EMAIL',
},
})
return { id: u.id, email: u.email }
}
async function makeTeamMember(projectId: string): Promise<string> {
const id = uid('teamuser')
const u = await prisma.user.create({
data: {
id,
email: `${id}@test.local`,
name: `Team ${id}`,
role: 'APPLICANT' as UserRole,
roles: ['APPLICANT'] as UserRole[],
status: 'ACTIVE',
notificationPreference: 'EMAIL',
},
})
await prisma.teamMember.create({
data: { projectId, userId: u.id, role: 'MEMBER' },
})
return u.id
}
async function attachToMentoringRound(
programId: string,
projectId: string,
status: 'ROUND_DRAFT' | 'ROUND_ACTIVE',
): Promise<string> {
const slug = uid()
const competition = await prisma.competition.create({
data: {
name: `Comp ${slug}`,
slug: `comp-${slug}`,
programId,
status: 'ACTIVE',
},
})
const round = await prisma.round.create({
data: {
name: `Mentoring ${slug}`,
slug: `mentoring-${slug}`,
roundType: 'MENTORING',
sortOrder: 1,
status,
competitionId: competition.id,
},
})
await prisma.projectRoundState.create({
data: { roundId: round.id, projectId },
})
return round.id
}
describe('mentor-assignment email deferral (regression for 2026-05-26 duplicate-email incident)', () => {
const programIds: string[] = []
const userIds: string[] = []
beforeEach(() => {
sendStyledMock.mockClear()
sendMentorBulkMock.mockClear()
sendTeamIntroMock.mockClear()
})
afterAll(async () => {
for (const programId of programIds) {
await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } })
await prisma.teamMember.deleteMany({ where: { project: { programId } } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
it('mentor.assign in DRAFT round creates in-app notif rows but sends ZERO emails', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `defer-draft-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Draft Project' })
await attachToMentoringRound(program.id, project.id, 'ROUND_DRAFT')
const mentor = await makeMentor()
userIds.push(mentor.id)
const teamUser = await makeTeamMember(project.id)
userIds.push(teamUser)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await caller.assign({ projectId: project.id, mentorId: mentor.id })
expect(sendStyledMock).not.toHaveBeenCalled()
expect(sendMentorBulkMock).not.toHaveBeenCalled()
expect(sendTeamIntroMock).not.toHaveBeenCalled()
// In-app notification rows still fire so admin + mentor see staged state.
const mentorNotifs = await prisma.inAppNotification.findMany({
where: { userId: mentor.id, type: 'MENTEE_ASSIGNED' },
})
expect(mentorNotifs.length).toBe(1)
const teamNotifs = await prisma.inAppNotification.findMany({
where: { userId: teamUser, type: 'MENTOR_ASSIGNED' },
})
expect(teamNotifs.length).toBe(1)
})
it('mentor.assign in ACTIVE round still sends the per-assignment emails (legacy behaviour preserved)', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `defer-active-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Active Project' })
await attachToMentoringRound(program.id, project.id, 'ROUND_ACTIVE')
const mentor = await makeMentor()
userIds.push(mentor.id)
const teamUser = await makeTeamMember(project.id)
userIds.push(teamUser)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await caller.assign({ projectId: project.id, mentorId: mentor.id })
// Either styled notif email OR the explicit team-intro email is allowed
// to fire here — point is: at least one outbound email happens when the
// round is open. The DRAFT test above is the one that must stay at zero.
const sentCount =
sendStyledMock.mock.calls.length + sendTeamIntroMock.mock.calls.length
expect(sentCount).toBeGreaterThan(0)
})
it('mentor.bulkAssign in DRAFT round sends ZERO emails across multiple projects', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `defer-bulk-${uid()}` })
programIds.push(program.id)
const p1 = await createTestProject(program.id, { title: 'BulkDraft 1' })
const p2 = await createTestProject(program.id, { title: 'BulkDraft 2' })
const p3 = await createTestProject(program.id, { title: 'BulkDraft 3' })
await attachToMentoringRound(program.id, p1.id, 'ROUND_DRAFT')
await attachToMentoringRound(program.id, p2.id, 'ROUND_DRAFT')
await attachToMentoringRound(program.id, p3.id, 'ROUND_DRAFT')
const mentor = await makeMentor()
userIds.push(mentor.id)
userIds.push(await makeTeamMember(p1.id))
userIds.push(await makeTeamMember(p2.id))
userIds.push(await makeTeamMember(p3.id))
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await caller.bulkAssign({
mentorIds: [mentor.id],
projectIds: [p1.id, p2.id, p3.id],
})
expect(sendStyledMock).not.toHaveBeenCalled()
expect(sendMentorBulkMock).not.toHaveBeenCalled()
expect(sendTeamIntroMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,98 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
vi.mock('@/lib/email', async () => {
const actual = await vi.importActual<typeof import('@/lib/email')>('@/lib/email')
return {
...actual,
sendMentorBulkAssignmentEmail: vi.fn(async () => true),
sendTeamMentorIntroductionEmail: vi.fn(async () => true),
}
})
import { prisma, createCaller } from '../setup'
import {
createTestProgram,
createTestCompetition,
createTestRound,
createTestProject,
createTestProjectRoundState,
createTestUser,
cleanupTestData,
uid,
} from '../helpers'
import { mentorRouter } from '../../src/server/routers/mentor'
/**
* Regression: a user whose MENTOR role is the PRIMARY role but whose roles[]
* array is empty (legacy/seeded records, e.g. prod user "Arnaud Blandin") must
* still be selectable + assignable on the mentor config page. Previously the
* candidate query filtered `roles: { has: 'MENTOR' }`, which excludes an empty
* roles[] even when role === 'MENTOR'.
*/
describe('mentor.getCandidates / bulkAssign — primary-role-only mentors are assignable', () => {
let programId = ''
let projectId = ''
let mentorId = ''
let adminId = ''
const userIds: string[] = []
beforeAll(async () => {
const program = await createTestProgram()
programId = program.id
const competition = await createTestCompetition(program.id)
const round = await createTestRound(competition.id, {
roundType: 'MENTORING',
status: 'ROUND_DRAFT', // draft → assignment emails are deferred
})
const project = await createTestProject(program.id)
projectId = project.id
await createTestProjectRoundState(project.id, round.id)
// The crux: primary role MENTOR, but roles[] is EMPTY.
const mentor = await prisma.user.create({
data: {
id: uid('u'),
email: `${uid('arnaud')}@test.local`,
name: 'Primary Only Mentor',
role: 'MENTOR',
roles: [],
status: 'ACTIVE',
},
})
mentorId = mentor.id
const admin = await createTestUser('SUPER_ADMIN')
adminId = admin.id
userIds.push(mentor.id, admin.id)
})
afterAll(async () => {
await cleanupTestData(programId, userIds)
})
it('getCandidates includes a primary-role-only mentor', async () => {
const caller = createCaller(mentorRouter, {
id: adminId,
email: 'admin@test.local',
role: 'SUPER_ADMIN',
})
const res = await caller.getCandidates({ projectId })
const ids = res.candidates.map((c: { id: string }) => c.id)
expect(ids).toContain(mentorId)
})
it('bulkAssign accepts a primary-role-only mentor and creates the assignment', async () => {
const caller = createCaller(mentorRouter, {
id: adminId,
email: 'admin@test.local',
role: 'SUPER_ADMIN',
})
// Must not throw "None of the selected users have the MENTOR role".
await caller.bulkAssign({ mentorIds: [mentorId], projectIds: [projectId] })
const assignment = await prisma.mentorAssignment.findFirst({
where: { projectId, mentorId },
})
expect(assignment).not.toBeNull()
})
})

View File

@@ -92,6 +92,7 @@ describe('mentor.getRoundStats', () => {
}) })
await prisma.mentorFile.create({ await prisma.mentorFile.create({
data: { data: {
projectId: projReqAssigned.id,
mentorAssignmentId: a1.id, mentorAssignmentId: a1.id,
uploadedByUserId: mentor.id, uploadedByUserId: mentor.id,
fileName: 'plan.pdf', fileName: 'plan.pdf',

View File

@@ -6,6 +6,7 @@ import {
const samplePayload: MentorUploadPayload = { const samplePayload: MentorUploadPayload = {
mentorAssignmentId: 'ma-123', mentorAssignmentId: 'ma-123',
projectId: 'proj-789',
uploaderUserId: 'user-456', uploaderUserId: 'user-456',
fileName: 'doc.pdf', fileName: 'doc.pdf',
mimeType: 'application/pdf', mimeType: 'application/pdf',

View File

@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest'
import {
getMentorBulkAssignmentTemplate,
getTeamMentorIntroductionTemplate,
} from '@/lib/email'
describe('getMentorBulkAssignmentTemplate', () => {
it('includes team-member emails, the instructions block, and a custom note', () => {
const t = getMentorBulkAssignmentTemplate(
'Alice',
[
{
title: 'Reef Project',
url: 'https://x/mentor/workspace/1',
teamMembers: [{ name: 'Bob', email: 'bob@team.com' }],
},
],
'https://x/mentor',
'Reach out <b>now</b> & soon',
)
expect(t.html).toContain('bob@team.com')
expect(t.html).toContain('How to mentor on MOPC')
// The admin-supplied custom note must be HTML-escaped in the HTML body.
expect(t.html).toContain('&lt;b&gt;')
expect(t.html).not.toContain('<b>now</b>')
expect(t.text).toContain('bob@team.com')
expect(t.text).toContain('How to mentor on MOPC')
// The plain-text body keeps the raw note unescaped.
expect(t.text).toContain('Reach out <b>now</b> & soon')
})
it('renders without team members or note (backward compatible)', () => {
const t = getMentorBulkAssignmentTemplate(
'Alice',
[{ title: 'P', url: 'https://x/p' }],
'https://x/mentor',
)
expect(t.html).toContain('How to mentor on MOPC')
// Custom-note box uses the #fff7ed background; absent when no note passed.
expect(t.html).not.toContain('#fff7ed')
})
})
describe('getTeamMentorIntroductionTemplate', () => {
it('includes mentor + teammate emails, the instructions block, and a custom note', () => {
const t = getTeamMentorIntroductionTemplate(
'Bob',
'Reef Project',
[{ name: 'Alice', email: 'alice@mentor.com' }],
'https://x/applicant/mentor',
[{ name: 'Carol', email: 'carol@team.com' }],
'Welcome aboard!',
)
expect(t.html).toContain('alice@mentor.com')
expect(t.html).toContain('carol@team.com')
expect(t.html).toContain('Working with your mentor')
expect(t.html).toContain('Welcome aboard!')
// Mirror coverage on the plain-text path.
expect(t.text).toContain('alice@mentor.com')
expect(t.text).toContain('carol@team.com')
expect(t.text).toContain('Welcome aboard!')
})
it('renders without teammates or note (backward compatible)', () => {
const t = getTeamMentorIntroductionTemplate(
'Bob',
'Reef Project',
[{ name: 'Alice', email: 'alice@mentor.com' }],
'https://x/applicant/mentor',
)
expect(t.html).toContain('Working with your mentor')
expect(t.html).not.toContain('#fff7ed')
})
})

View File

@@ -8,6 +8,7 @@ import { signMentorUploadToken } from '../../src/lib/mentor-upload-token'
describe('mentor.workspace files end-to-end', () => { describe('mentor.workspace files end-to-end', () => {
let programId: string let programId: string
let projectId: string
let mentor: { id: string; email: string; role: 'MENTOR' } let mentor: { id: string; email: string; role: 'MENTOR' }
let outsider: { id: string; email: string; role: 'JURY_MEMBER' } let outsider: { id: string; email: string; role: 'JURY_MEMBER' }
let assignmentId: string let assignmentId: string
@@ -18,6 +19,7 @@ describe('mentor.workspace files end-to-end', () => {
const program = await createTestProgram({ name: `mentor-files-${uid()}` }) const program = await createTestProgram({ name: `mentor-files-${uid()}` })
programId = program.id programId = program.id
const project = await createTestProject(programId, { title: 'Test Project' }) const project = await createTestProject(programId, { title: 'Test Project' })
projectId = project.id
const m = await createTestUser('MENTOR') const m = await createTestUser('MENTOR')
userIds.push(m.id) userIds.push(m.id)
@@ -79,6 +81,7 @@ describe('mentor.workspace files end-to-end', () => {
it('rejects workspaceUploadFile with a token whose uploader differs from the caller', async () => { it('rejects workspaceUploadFile with a token whose uploader differs from the caller', async () => {
const forged = signMentorUploadToken({ const forged = signMentorUploadToken({
mentorAssignmentId: assignmentId, mentorAssignmentId: assignmentId,
projectId,
uploaderUserId: 'someone-else', uploaderUserId: 'someone-else',
fileName: 'x.pdf', mimeType: 'application/pdf', size: 1, fileName: 'x.pdf', mimeType: 'application/pdf', size: 1,
bucket: 'mopc-files', objectKey: 'a/mentorship/0-x.pdf', bucket: 'mopc-files', objectKey: 'a/mentorship/0-x.pdf',
@@ -94,7 +97,7 @@ describe('mentor.workspace files end-to-end', () => {
mentorAssignmentId: assignmentId, fileName: 'b.pdf', mimeType: 'application/pdf', size: 50, mentorAssignmentId: assignmentId, fileName: 'b.pdf', mimeType: 'application/pdf', size: 50,
}) })
await caller.workspaceUploadFile({ uploadToken: a.uploadToken }) await caller.workspaceUploadFile({ uploadToken: a.uploadToken })
const files = await caller.workspaceGetFiles({ mentorAssignmentId: assignmentId }) const files = await caller.workspaceGetFiles({ projectId })
expect(files.length).toBeGreaterThanOrEqual(2) expect(files.length).toBeGreaterThanOrEqual(2)
expect(new Date(files[0].createdAt).getTime()).toBeGreaterThanOrEqual( expect(new Date(files[0].createdAt).getTime()).toBeGreaterThanOrEqual(
new Date(files[1].createdAt).getTime(), new Date(files[1].createdAt).getTime(),
@@ -104,7 +107,7 @@ describe('mentor.workspace files end-to-end', () => {
it('refuses workspaceGetFiles to outsiders', async () => { it('refuses workspaceGetFiles to outsiders', async () => {
const caller = createCaller(mentorRouter, outsider) const caller = createCaller(mentorRouter, outsider)
await expect( await expect(
caller.workspaceGetFiles({ mentorAssignmentId: assignmentId }) caller.workspaceGetFiles({ projectId })
).rejects.toThrow(/FORBIDDEN|not a member/i) ).rejects.toThrow(/FORBIDDEN|not a member/i)
}) })

View File

@@ -0,0 +1,107 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
vi.mock('@/lib/email', async () => {
const actual = await vi.importActual<typeof import('@/lib/email')>('@/lib/email')
return {
...actual,
sendMentorBulkAssignmentEmail: vi.fn(async () => true),
sendTeamMentorIntroductionEmail: vi.fn(async () => true),
}
})
import { prisma, createCaller } from '../setup'
import {
createTestProgram,
createTestCompetition,
createTestRound,
createTestProject,
createTestProjectRoundState,
createTestUser,
cleanupTestData,
} from '../helpers'
import { mentorRouter } from '../../src/server/routers/mentor'
import {
sendMentorBulkAssignmentEmail,
sendTeamMentorIntroductionEmail,
} from '@/lib/email'
describe('mentor.sendMentorshipWelcome / previewMentorshipWelcome', () => {
let programId: string
const userIds: string[] = []
let roundId: string
let memberEmail: string
let mentorEmail: string
beforeAll(async () => {
const program = await createTestProgram()
programId = program.id
const competition = await createTestCompetition(program.id)
const round = await createTestRound(competition.id, {
roundType: 'MENTORING',
status: 'ROUND_ACTIVE',
})
roundId = round.id
const project = await createTestProject(program.id)
await createTestProjectRoundState(project.id, round.id)
const mentor = await createTestUser('MENTOR')
const member = await createTestUser('APPLICANT')
mentorEmail = mentor.email
memberEmail = member.email
userIds.push(mentor.id, member.id)
await prisma.teamMember.create({
data: { projectId: project.id, userId: member.id, role: 'LEAD' },
})
await prisma.mentorAssignment.create({
data: { projectId: project.id, mentorId: mentor.id, method: 'MANUAL' },
})
})
afterAll(async () => {
await cleanupTestData(programId, userIds)
})
it('sends to mentors and team members and reports counts', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const res = await caller.sendMentorshipWelcome({ roundId, customNote: 'Reminder!' })
expect(res.sent).toBeGreaterThan(0)
expect(sendMentorBulkAssignmentEmail).toHaveBeenCalled()
expect(sendTeamMentorIntroductionEmail).toHaveBeenCalled()
})
it('does NOT stamp the one-time flags (re-sendable reminder)', async () => {
const assignment = await prisma.mentorAssignment.findFirst({
where: { project: { projectRoundStates: { some: { roundId } } } },
})
expect(assignment?.notificationSentAt).toBeNull()
expect(assignment?.teamIntroducedAt).toBeNull()
})
it('preview returns non-empty mentor + team HTML with real contacts', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const pv = await caller.previewMentorshipWelcome({ roundId })
expect(pv.recipientCount).toBeGreaterThan(0)
expect(pv.html).toContain('Mentor version')
expect(pv.html).toContain('Team version')
expect(pv.html).toContain(memberEmail)
expect(pv.html).toContain(mentorEmail)
})
})

View File

@@ -0,0 +1,546 @@
/**
* PR8 — Multi-mentor stacking + change-request procedures
*
* Covers the API surface added by PR8 Tasks 4 + 6:
* - mentor.assign: per-team stacking, P2002 on duplicate (projectId, mentorId),
* idempotent per-row email notification (via MentorAssignment.notificationSentAt),
* re-assignment after drop creates a new row and re-fires the email.
* - mentor.requestChange: auth (team-member or admin), validation, single open
* request per (user, project), target-assignment cross-project guard.
* - mentor.listChangeRequests: admin-only, PENDING-first ordering.
* - mentor.resolveChangeRequest: admin-only, BAD_REQUEST on already-resolved.
*/
import { afterAll, describe, expect, it } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser,
createTestProgram,
createTestProject,
cleanupTestData,
uid,
} from '../helpers'
import { mentorRouter } from '../../src/server/routers/mentor'
import type { UserRole } from '@prisma/client'
async function createUserWithRoles(
primaryRole: UserRole,
rolesArray: UserRole[],
overrides: { name?: string; expertiseTags?: string[] } = {},
) {
const id = uid('user')
return prisma.user.create({
data: {
id,
email: `${id}@test.local`,
name: overrides.name ?? `Test ${primaryRole}`,
role: primaryRole,
roles: rolesArray,
status: 'ACTIVE',
expertiseTags: overrides.expertiseTags ?? [],
},
})
}
/**
* mentor.assign and mentor.bulkAssign now require the project to be enrolled
* in some MENTORING round. This helper sets up the minimum: one competition
* + one MENTORING round + one ProjectRoundState linking the project.
*/
async function attachToMentoringRound(programId: string, projectId: string) {
const compSlug = `comp-${uid()}`
const competition = await prisma.competition.create({
data: {
name: `Comp ${compSlug}`,
slug: compSlug,
programId,
status: 'ACTIVE',
},
})
const round = await prisma.round.create({
data: {
name: `Mentoring ${uid()}`,
slug: `mentoring-${uid()}`,
roundType: 'MENTORING',
sortOrder: 1,
status: 'ROUND_ACTIVE',
competitionId: competition.id,
},
})
await prisma.projectRoundState.create({
data: { roundId: round.id, projectId },
})
return { competitionId: competition.id, roundId: round.id }
}
describe('mentor.assign — stacking + per-team email idempotency', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
for (const programId of programIds) {
await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
it('stacks two different mentors on the same project (both rows active)', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `assign-stack-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Stacking Project' })
await attachToMentoringRound(program.id, project.id)
const m1 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'M1' })
const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'M2' })
userIds.push(m1.id, m2.id)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const a1 = await caller.assign({ projectId: project.id, mentorId: m1.id })
const a2 = await caller.assign({ projectId: project.id, mentorId: m2.id })
expect(a1.id).not.toBe(a2.id)
expect(a1.mentorId).toBe(m1.id)
expect(a2.mentorId).toBe(m2.id)
const rows = await prisma.mentorAssignment.findMany({
where: { projectId: project.id },
orderBy: { assignedAt: 'asc' },
})
expect(rows).toHaveLength(2)
expect(rows.every((r) => r.droppedAt === null)).toBe(true)
})
it('rejects duplicate (projectId, mentorId) pair with CONFLICT', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `assign-dup-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Dup Project' })
await attachToMentoringRound(program.id, project.id)
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
userIds.push(mentor.id)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await caller.assign({ projectId: project.id, mentorId: mentor.id })
await expect(
caller.assign({ projectId: project.id, mentorId: mentor.id }),
).rejects.toThrow(/already assigned/i)
})
it('stamps notificationSentAt on first assignment; fires fresh email when same mentor is added to a different project', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `assign-email-${uid()}` })
programIds.push(program.id)
const project1 = await createTestProject(program.id, { title: 'Project Alpha' })
await attachToMentoringRound(program.id, project1.id)
const project2 = await createTestProject(program.id, { title: 'Project Beta' })
await attachToMentoringRound(program.id, project2.id)
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
userIds.push(mentor.id)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const a1 = await caller.assign({ projectId: project1.id, mentorId: mentor.id })
const a2 = await caller.assign({ projectId: project2.id, mentorId: mentor.id })
// assign() returns the row before the post-write stamp; re-read for the
// current value.
const row1 = await prisma.mentorAssignment.findUnique({ where: { id: a1.id } })
const row2 = await prisma.mentorAssignment.findUnique({ where: { id: a2.id } })
expect(row1?.notificationSentAt).not.toBeNull()
expect(row2?.notificationSentAt).not.toBeNull()
// Each row carries its own timestamp — they're independent.
expect(row1?.id).not.toBe(row2?.id)
})
it('stamps notificationSentAt independently for each co-mentor on the same project', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `assign-comentor-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Co-mentor Project' })
await attachToMentoringRound(program.id, project.id)
const m1 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'Co-1' })
const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'Co-2' })
userIds.push(m1.id, m2.id)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const a1 = await caller.assign({ projectId: project.id, mentorId: m1.id })
const a2 = await caller.assign({ projectId: project.id, mentorId: m2.id })
const row1 = await prisma.mentorAssignment.findUnique({ where: { id: a1.id } })
const row2 = await prisma.mentorAssignment.findUnique({ where: { id: a2.id } })
expect(row1?.notificationSentAt).not.toBeNull()
expect(row2?.notificationSentAt).not.toBeNull()
})
it('after a mentor is dropped (assignment row deleted), re-assigning creates a fresh row with a new notificationSentAt', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `assign-redrop-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Re-assign Project' })
await attachToMentoringRound(program.id, project.id)
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
userIds.push(mentor.id)
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const a1 = await caller.assign({ projectId: project.id, mentorId: mentor.id })
const stamp1 = (
await prisma.mentorAssignment.findUnique({ where: { id: a1.id } })
)?.notificationSentAt
expect(stamp1).not.toBeNull()
// Hard-delete the first row (simulates a "fully dropped → repository clean"
// state — the unique constraint also blocks any re-assign while the row
// exists, so the row must go away).
await prisma.mentorAssignment.delete({ where: { id: a1.id } })
// Re-assign: new row, new notificationSentAt stamp.
const a2 = await caller.assign({ projectId: project.id, mentorId: mentor.id })
const stamp2 = (
await prisma.mentorAssignment.findUnique({ where: { id: a2.id } })
)?.notificationSentAt
expect(a2.id).not.toBe(a1.id)
expect(stamp2).not.toBeNull()
})
})
describe('mentor.requestChange / listChangeRequests / resolveChangeRequest', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
for (const programId of programIds) {
await prisma.mentorChangeRequest.deleteMany({ where: { project: { programId } } })
await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } })
await prisma.teamMember.deleteMany({ where: { project: { programId } } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
/**
* Builds a project with a LEAD team member (applicant), an unrelated
* non-team-member (applicant), and an admin. Returns the IDs.
*/
async function setupProjectWithTeam(label: string) {
const admin = await createTestUser('SUPER_ADMIN', { name: `Admin ${label}` })
userIds.push(admin.id)
const program = await createTestProgram({ name: `${label}-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: `Project ${label}` })
const teamMember = await createUserWithRoles('APPLICANT', ['APPLICANT'], {
name: `Team ${label}`,
})
const outsider = await createUserWithRoles('APPLICANT', ['APPLICANT'], {
name: `Outsider ${label}`,
})
userIds.push(teamMember.id, outsider.id)
await prisma.teamMember.create({
data: {
projectId: project.id,
userId: teamMember.id,
role: 'LEAD',
},
})
return { admin, program, project, teamMember, outsider }
}
it('team member can open a change request (PENDING)', async () => {
const { project, teamMember } = await setupProjectWithTeam('rc-teamok')
const caller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
const created = await caller.requestChange({
projectId: project.id,
reason: 'We would like a mentor with deeper marine biology experience.',
})
expect(created.status).toBe('PENDING')
const persisted = await prisma.mentorChangeRequest.findUnique({
where: { id: created.id },
})
expect(persisted?.requestedByUserId).toBe(teamMember.id)
expect(persisted?.projectId).toBe(project.id)
})
it('non-team-member non-admin is rejected with FORBIDDEN', async () => {
const { project, outsider } = await setupProjectWithTeam('rc-outsider')
const caller = createCaller(mentorRouter, {
id: outsider.id,
email: outsider.email,
role: 'APPLICANT',
})
await expect(
caller.requestChange({
projectId: project.id,
reason: 'I have no business asking for this.',
}),
).rejects.toThrow(/FORBIDDEN|not a member/i)
})
it('admin (no team membership) can open a change request', async () => {
const { admin, project } = await setupProjectWithTeam('rc-admin')
const caller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const created = await caller.requestChange({
projectId: project.id,
reason: 'Admin-initiated mentor swap due to internal escalation.',
})
expect(created.status).toBe('PENDING')
})
it('reason < 10 chars is rejected (Zod validation)', async () => {
const { project, teamMember } = await setupProjectWithTeam('rc-short')
const caller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
await expect(
caller.requestChange({ projectId: project.id, reason: 'too short' }),
).rejects.toThrow()
})
it('opening a second request while the first is still PENDING throws CONFLICT', async () => {
const { project, teamMember } = await setupProjectWithTeam('rc-conflict')
const caller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
await caller.requestChange({
projectId: project.id,
reason: 'First request — still pending please review.',
})
await expect(
caller.requestChange({
projectId: project.id,
reason: 'Second request while first is open.',
}),
).rejects.toThrow(/already.*open|CONFLICT/i)
})
it('after the first request is resolved, the same user can open a new one', async () => {
const { admin, project, teamMember } = await setupProjectWithTeam('rc-reopen')
const teamCaller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
const adminCaller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const first = await teamCaller.requestChange({
projectId: project.id,
reason: 'First request — please address my concerns.',
})
await adminCaller.resolveChangeRequest({
id: first.id,
status: 'RESOLVED',
resolutionNote: 'Mentor swapped.',
})
const second = await teamCaller.requestChange({
projectId: project.id,
reason: 'Second request — new concern after resolution.',
})
expect(second.status).toBe('PENDING')
expect(second.id).not.toBe(first.id)
})
it('targetAssignmentId belonging to a different project is rejected with BAD_REQUEST', async () => {
const { admin, project: projectA, teamMember } = await setupProjectWithTeam('rc-crossproj')
// Make a second project + mentor assignment NOT on the requester's project.
const otherProgram = await createTestProgram({ name: `rc-other-${uid()}` })
programIds.push(otherProgram.id)
const otherProject = await createTestProject(otherProgram.id, { title: 'Other proj' })
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
userIds.push(mentor.id)
const foreignAssignment = await prisma.mentorAssignment.create({
data: {
projectId: otherProject.id,
mentorId: mentor.id,
method: 'MANUAL',
assignedBy: admin.id,
},
})
const caller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
await expect(
caller.requestChange({
projectId: projectA.id,
targetAssignmentId: foreignAssignment.id,
reason: 'Trying to point at a foreign assignment row.',
}),
).rejects.toThrow(/does not belong|BAD_REQUEST/i)
})
it('listChangeRequests is FORBIDDEN for applicant', async () => {
const { project, teamMember } = await setupProjectWithTeam('rc-list-forbidden')
const teamCaller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
await teamCaller.requestChange({
projectId: project.id,
reason: 'A real request, but list should still be admin-only.',
})
await expect(teamCaller.listChangeRequests({})).rejects.toThrow()
})
it('listChangeRequests returns PENDING rows before non-PENDING rows', async () => {
const { admin, project, teamMember } = await setupProjectWithTeam('rc-list-order')
const teamCaller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
const adminCaller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
// Create two requests: open one, resolve it; open a second one (still PENDING).
const resolvedReq = await teamCaller.requestChange({
projectId: project.id,
reason: 'Will be resolved before the second request opens.',
})
await adminCaller.resolveChangeRequest({
id: resolvedReq.id,
status: 'RESOLVED',
})
const pendingReq = await teamCaller.requestChange({
projectId: project.id,
reason: 'Still pending — should be listed first.',
})
const rows = (await adminCaller.listChangeRequests({ projectId: project.id })) as Array<{
id: string
status: string
}>
const ids = rows.map((r) => r.id)
// PENDING must come before RESOLVED in the listing.
expect(ids.indexOf(pendingReq.id)).toBeLessThan(ids.indexOf(resolvedReq.id))
expect(rows[0].status).toBe('PENDING')
})
it('resolveChangeRequest sets resolvedBy/resolvedAt/resolutionNote', async () => {
const { admin, project, teamMember } = await setupProjectWithTeam('rc-resolve')
const teamCaller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
const adminCaller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const req = await teamCaller.requestChange({
projectId: project.id,
reason: 'Please resolve this request.',
})
const result = await adminCaller.resolveChangeRequest({
id: req.id,
status: 'RESOLVED',
resolutionNote: 'Replacement mentor assigned.',
})
expect(result.status).toBe('RESOLVED')
expect(result.resolvedByUserId).toBe(admin.id)
expect(result.resolvedAt).not.toBeNull()
expect(result.resolutionNote).toBe('Replacement mentor assigned.')
})
it('resolveChangeRequest by non-admin is FORBIDDEN', async () => {
const { admin, project, teamMember } = await setupProjectWithTeam('rc-resolve-forbid')
const teamCaller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
const adminCaller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const req = await adminCaller.requestChange({
projectId: project.id,
reason: 'Admin opens, applicant should not resolve.',
})
await expect(
teamCaller.resolveChangeRequest({ id: req.id, status: 'RESOLVED' }),
).rejects.toThrow()
})
it('resolveChangeRequest on an already-resolved request throws BAD_REQUEST', async () => {
const { admin, project, teamMember } = await setupProjectWithTeam('rc-resolve-twice')
const teamCaller = createCaller(mentorRouter, {
id: teamMember.id,
email: teamMember.email,
role: 'APPLICANT',
})
const adminCaller = createCaller(mentorRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const req = await teamCaller.requestChange({
projectId: project.id,
reason: 'Will resolve, then try to resolve again.',
})
await adminCaller.resolveChangeRequest({ id: req.id, status: 'RESOLVED' })
await expect(
adminCaller.resolveChangeRequest({ id: req.id, status: 'DISMISSED' }),
).rejects.toThrow(/already resolved|BAD_REQUEST/i)
})
})

View File

@@ -0,0 +1,43 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { prisma, createCaller } from '../setup'
import { createTestProgram, createTestUser, cleanupTestData, uid } from '../helpers'
import { userRouter } from '../../src/server/routers/user'
/**
* Regression: user-creation paths must populate roles[] with the primary role,
* so the invariant role ∈ roles holds for new users (prevents the empty-roles[]
* inconsistency that made primary-role mentors un-addable). Covers the admin
* `user.create` path as the representative case.
*/
describe('user.create — populates roles[] with the primary role', () => {
let programId = ''
const userIds: string[] = []
beforeAll(async () => {
const program = await createTestProgram()
programId = program.id
})
afterAll(async () => {
await cleanupTestData(programId, userIds)
})
it('sets roles=[role] on an admin-created user', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const caller = createCaller(userRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const email = `${uid('created')}@test.local`
await caller.create({ email, name: 'New Member', role: 'JURY_MEMBER' })
const created = await prisma.user.findUnique({ where: { email } })
expect(created).not.toBeNull()
if (created) userIds.push(created.id)
expect(created?.role).toBe('JURY_MEMBER')
expect(created?.roles).toEqual(['JURY_MEMBER'])
})
})