# Mentor Round Readiness — End-to-End Design **Date:** 2026-04-28 **Author:** Matt + Claude (brainstorming session) **Status:** Draft, awaiting review ## Motivation R5 (Semi-Final Evaluation) is about to close. Next is R6 (Mentoring) for projects that request or are assigned a mentor, then R7 (Grand Final). The MENTORING backend exists but has gaps that block operational use: - Admin Config form omits two `MentoringConfigSchema` fields (`mentoringRequestDeadlineDays`, `passThroughIfNoRequest`) - Round Overview shows generic stats only — no mentor-specific dashboard - `/admin/projects/[id]/mentor` exposes only AI suggestions; manual mentor selection is missing entirely from the UI - File uploads (`mentor.workspaceUploadFile`) accept client-controlled `bucket` / `objectKey` — security/consistency hole - Juror "Confirm Your Evaluation Preferences" banner pulls in LIVE_FINAL groups (not appropriate for a live ceremony) - Multi-role users (juror + mentor) land on primary role's dashboard only; no quick path for an admin to bulk-promote jurors - Zero tests for MENTORING round behavior This spec covers all of the above plus workspace messaging/file UX polish, in one design with phased PRs. ## Goals 1. Admin can fully configure a MENTORING round from the UI (no DB-direct edits needed for any `MentoringConfigSchema` field). 2. Admin can see at a glance: who requested mentoring, who has a mentor, who doesn't, who's mentoring whom, what the mentor pool looks like. 3. Admin can manually assign a mentor to any project, AND auto-fill all unassigned projects in one action. 4. Files uploaded in the mentor workspace land at `/mentorship/` in the configured bucket, with paths constructed server-side. 5. Mentors and applicant teams see recent messages on their respective dashboards. 6. A juror who is also a mentor can switch dashboards in one click, without seeing irrelevant LIVE_FINAL preference cards. 7. The MENTORING round behavior (pass-through, eligibility, advancement) is covered by integration tests. ## Non-goals - Redesigning messaging or notifications from scratch. - Replacing the AI mentor-matching service with a different model. - Building a mentor scheduling/calendar feature. - Bulk-promoting jurors to mentors via CSV import (per-row checkbox + bulk action is enough for this iteration). - Migrating any existing mentor file objects in MinIO (none exist yet — spec asserts a pre-flight check). ## Out-of-scope but adjacent - Grand Finale (R7 LIVE_FINAL) UX — explicitly deferred per user direction (handled separately, much further build-out planned). - Mentor pool capacity / load-balancing algorithm changes — covered only by surfacing existing fields in the admin view. --- ## High-level architecture No new top-level architecture. Extending existing patterns: - **Storage path:** new helper `generateMentorObjectKey(projectTitle, fileName)` in `src/lib/minio.ts` that returns `/mentorship/-` — exact same shape as `generateObjectKey()` with `roundName="mentorship"`. Server-side only. - **Config schema:** no Prisma migration. The two missing fields (`mentoringRequestDeadlineDays`, `passThroughIfNoRequest`) already exist in `MentoringConfigSchema` and are read by `round-engine.ts` and `applicant.ts` — only the form needs updating. - **Multi-role dashboards:** existing `User.roles UserRole[]` array drives everything; logic-only changes (post-login redirect priority, bulk-promote bulk action, fix CSS layering on impersonation banner). - **Preferences filter:** single Prisma query change in `getOnboardingContext`. - **Workspace dashboards:** reuse existing `MentorMessage` table; new tRPC procedures return last-N message previews. ## Phasing / PR plan Six PRs, ordered smallest-blast-radius first: | PR | Section | Risk | What ships | |----|---------|------|------------| | 1 | §E | Low | Filter `getOnboardingContext` to review-only rounds | | 2 | §F.1 | Low | Server-side `objectKey` enforcement + `generateMentorObjectKey` helper | | 3 | §A | Med | Config form completeness (2 missing inputs + General Settings cleanup + Launch Readiness gate relax) | | 4 | §C | Med | Manual mentor picker + bulk auto-fill + AI fallback | | 5 | §B | Med | Mentor-specific Round Overview + un-redirect `/admin/mentors` | | 6 | §D + §F.2 | Med | Multi-role redirect priority + bulk-promote + impersonation banner fix + dashboard message previews | | (continuous) | §G | Low | Tests added in each PR for the surface changing in that PR | A standalone test PR is *not* planned — tests ride with the change they cover. --- ## §A. MENTORING round Config form **Files:** - `src/components/admin/round-config/mentoring-config.tsx` (likely path; locate the round-type-specific config component used by `(admin)/admin/rounds/[roundId]` Config tab) - `src/components/admin/round-config/launch-readiness.tsx` (or similar — the component that renders the 0/3 readiness checklist) **Changes:** 1. Add **"Mentoring Request Window"** section to the Config form: - Numeric input bound to `configJson.mentoringRequestDeadlineDays` — int, min 1, max 90, default 14. - Help text: "Number of days from round opening during which teams may request mentoring. After this window, no new requests are accepted." 2. Add **"Pass-through behavior"** toggle bound to `configJson.passThroughIfNoRequest`: - Default `true` (matches schema default). - Off-state label: "Hold all projects in PENDING until mentor is assigned (manual gate)" - On-state label: "Auto-PASS projects that don't request mentoring (default)" 3. Replace empty **"General Settings"** section header. Either: - Delete the empty header (preferred — fewer questions); OR - Move the eligibility dropdown into it (so the section has content). 4. Relax Launch Readiness "File requirements set" gate for MENTORING rounds: - Required only when `configJson.filePromotionEnabled === true` AND `configJson.promotionTargetWindowId` is set (i.e., the round is configured to promote mentor-authored files into a downstream submission window). - Otherwise treat the readiness item as N/A and don't count it against the 0/3 (it becomes 0/2 for mentoring rounds without promotion configured). 5. Help-text added to the existing **Eligibility** dropdown explaining each option: - `requested_only` — only projects that flag `mentoringRequested` participate (default). - `all_advancing` — every project advancing into this round gets a mentor. - `admin_selected` — admin manually picks which projects participate. **Tests** (in PR 3): one per `MentoringConfigSchema` field — render with default config, change input, submit, assert config persisted via the existing config-save mutation. --- ## §B. Mentoring-specific admin views **Files:** - `src/app/(admin)/admin/rounds/[roundId]/page.tsx` (Round Overview tab) - `src/app/(admin)/admin/rounds/[roundId]/projects-tab.tsx` (Projects tab — exact filename to confirm during impl) - `src/app/(admin)/admin/mentors/page.tsx` (currently a redirect stub — replace with a real list page) - `src/app/(admin)/admin/mentors/[id]/page.tsx` (also a stub today; replace with mentor detail) - New tRPC procedures on `mentor` router (admin-gated): `getRoundStats`, `getMentorPool`, `getMentorDetail` **Round Overview — replace generic Round Details with a mentoring-specific stats card** when `round.roundType === 'MENTORING'`: - **Top-line counts** (single row of stat cards): - Total projects in round - Requested mentoring (count + % of total) - Mentor assigned (count + % of total) - Awaiting assignment (= requested - assigned) - **Request window** card: - Deadline (computed from `windowOpenAt + mentoringRequestDeadlineDays`) - Time remaining (live countdown, using existing `formatCountdown` helper) - "Closes in N days" pill, turns amber within 48 hours, red within 12 hours - **Mentor pool** card: - Pool size (count of users with MENTOR role in the program) - Average load (assigned projects ÷ pool size) - Capacity remaining (sum of `User.maxAssignmentsOverride` minus current load, where overrides exist) - Link → `/admin/mentors` - **Workspace activity** card: - Total messages exchanged (sum across all assignments in round) - Total files uploaded - Total milestones completed - "Last activity" timestamp **Round Details panel** stays at the bottom of the Overview tab when round is MENTORING (the existing panel is still useful for type/status/position/dates), but with these field-level adjustments: - Replace "Jury Group: —" row with "Mentor Pool: N members" (link to `/admin/mentors`). - Keep "Type", "Status", "Position", "Opens", "Closes" rows unchanged. - The new "mentoring stats card" (top-line counts, request window, mentor pool, workspace activity) renders **above** the Round Details panel, not in place of it. **Projects tab — when round is MENTORING**, the per-project row shows: - Project title + team lead - "Requested mentoring" badge (yes/no) - "Mentor assigned" cell — mentor name + expertise overlap chip, OR "Unassigned" with inline "Assign" button → opens the manual-pick drawer (see §C) - "Workspace activity" small-text summary (msgs / files / milestones) - Bulk action bar (when ≥1 project selected): "Auto-fill mentors for selected" → calls `mentor.autoAssignBulk` **`/admin/mentors` — un-redirect, replace stub with a real list page:** - Searchable/filterable list of all users with MENTOR role in the current edition. - Columns: name, email, country, expertise tags (chips), assigned-projects count, completed count, capacity remaining, last activity. - Row → `/admin/mentors/[id]` detail page (existing route, replace stub): - Mentor profile + expertise + bio - List of assigned projects (link to per-project workspace) - Per-project status (in_progress / completed / paused) - Recent activity feed (messages / file uploads / milestone completions across all assignments) - Admin actions: reassign / unassign **Tests** (in PR 5): integration test for `getRoundStats` returning correct counts; render-test for round overview when round.roundType=MENTORING. --- ## §C. Manual + auto-fill mentor assignment **Files:** - `src/app/(admin)/admin/projects/[id]/mentor/page.tsx` (rewrite) - `src/server/services/mentor-matching.ts` (add expertise-tag fallback) - `src/server/routers/mentor.ts` (`getCandidates` new procedure for manual picker; ensure `autoAssignBulk` exposes a "skip already assigned" param — confirm and document) **Page rewrite — three sections, all visible at once (not tabs):** 1. **Project Context** card (top): - Project title, ocean issue, country, team size, expertise needs (project tags) - Round being assigned for (linked) - Mentoring requested? Yes/no 2. **Currently Assigned** card: - If assigned: mentor name, email, country, expertise overlap chips, "Assigned by [admin], 3 days ago, method: MANUAL/AUTO", actions: Unassign | Swap - If unassigned: empty state with copy "No mentor assigned yet — pick one below or use AI" 3. **Pick a mentor** card with a tab strip: - **Tab 1 — Manual picker** (default selected): - Searchable input - Sortable table of all MENTOR-role users in the program: name, expertise tags, country, current load, capacity, **expertise overlap with this project** (computed: count of shared tags / total project tags, displayed as a percentage chip) - Default sort: highest expertise overlap first - Per-row "Assign" button → calls `mentor.assign({ projectId, mentorId, method: 'MANUAL' })` - **Tab 2 — AI suggestions**: - Existing pane (loads `getSuggestions`). - **Fallback**: if AI fails (no `OPENAI_API_KEY`, network error, or returns empty) — show expertise-tag-overlap ranking as the suggestion source instead, with a banner: "AI matching unavailable — showing expertise-tag overlap instead". (The fallback ranking is the same algorithm as Tab 1's default sort, so the lists may look similar — that's fine.) **Auto-fill remainder** (bulk action): - On round Projects tab + Round Overview, button: "Auto-fill mentors for unassigned projects". - Call `mentor.autoAssignBulk` with the round ID; the service filters to projects-in-round-without-MentorAssignment, scoped further by the round's `eligibility` config: - `requested_only` → only projects with `mentoringRequested=true` - `all_advancing` → every project in the round - `admin_selected` → button disabled (admins must pick manually for this mode) - Confirm the existing service already skips projects with a MentorAssignment (any method); if it doesn't, fix in the same PR. - Result toast: "Assigned N projects, skipped M already-assigned, K unassignable (no matching mentor)". **Tests** (in PR 4): - `mentor.assign` round-trips with method=MANUAL - `mentor.autoAssignBulk` skips manually-assigned projects - `getCandidates` returns expected expertise-overlap ordering - Fallback path used when AI unavailable --- ## §D. Juror→mentor multi-role UX **Files:** - `src/app/page.tsx` (post-login redirect) - `src/app/(admin)/admin/members/page.tsx` (bulk action) - `src/components/layouts/role-nav.tsx` (no change — switcher already correct) - `src/components/layouts/impersonation-banner.tsx` (or wherever the banner lives — find via grep) - `src/server/routers/user.ts` (new `bulkUpdateRoles` mutation if not exists) - `src/lib/email/templates/mentor-onboarding.tsx` (new) - `src/server/services/notifications.ts` (or equivalent — call site to send mentor-onboarding email when MENTOR role is freshly added to a user) **1. Post-login redirect — context-aware "go where the work is":** Replace single-`role` switch in `src/app/page.tsx` with a priority list that is *filtered by actionable work*. The user lands on the highest-priority role for which they have something to do right now; if no role has active work, fall back to the static priority order. New tRPC query: `user.getDefaultDashboard()` (server-side, called from `src/app/page.tsx`): ```ts // Static priority — used as fallback ordering AND as the order we check for work. const ROLE_DASHBOARD_PRIORITY: Array<[UserRole, Route]> = [ ['SUPER_ADMIN', '/admin'], ['PROGRAM_ADMIN', '/admin'], ['AWARD_MASTER', '/award-master'], ['JURY_MEMBER', '/jury'], ['MENTOR', '/mentor'], ['APPLICANT', '/applicant'], ['OBSERVER', '/observer'], ['AUDIENCE', '/audience'], ] ``` For each role the user holds (in priority order), the server checks "does this user have actionable work in this role right now?": | Role | "Has actionable work" predicate | |------|---------------------------------| | SUPER_ADMIN / PROGRAM_ADMIN | Always true (admin work is always present) | | AWARD_MASTER | Any unfinalized award decision in an active round in current edition | | JURY_MEMBER | Any `JuryAssignment` linked to a round whose `status = ROUND_ACTIVE` AND the user has at least one PENDING evaluation | | MENTOR | Any `MentorAssignment` whose linked round is `ROUND_ACTIVE` AND `workspaceEnabled = true` | | APPLICANT | Any `Project` led by user with at least one `ProjectRoundState` in a non-terminal state in an active round | | OBSERVER | Always false (observers have nothing to act on) | | AUDIENCE | Always false | Algorithm: 1. Try roles in priority order. Return the first role whose predicate is true. 2. If no role has actionable work, return the highest-priority role the user holds (static fallback). 3. Always end with a non-null route (worst case: any signed-in user has at least their primary role). **Why this matters (your example):** a juror+observer who logs in during an open jury round lands on `/jury` (because they have a pending evaluation), not `/observer`. A mentor+juror logs in during an active MENTORING round → `/mentor`. After both rounds close, same user logs in → static fallback (jury > mentor) → `/jury`. The role switcher in the user menu is always available to override. **Decision: context-aware, not "remember last view".** "Remember last view" requires a new column and surprises users when their last context disappears (round closed, role removed). Context-aware is deterministic, explains itself, and handles the cross-role overlap cleanly. The role switcher dropdown is the user's escape hatch. **Tests** (in PR 6): - Juror with pending evaluation in active round + Observer → `/jury` - Juror with no active assignments + Observer → `/jury` (fallback to static priority) - Mentor+Juror, MENTORING round active, no jury work → `/mentor` - Mentor+Juror, both rounds active with work in both → `/jury` (priority order breaks the tie) - Observer-only user → `/observer` - Multi-role with no active work anywhere → static-priority fallback **2. Bulk juror→mentor promotion** on `/admin/members`: - Add row checkboxes to the Members table (already a table — confirm during impl). - When ≥1 row selected, surface a bulk action toolbar with "Add role…" dropdown (OBSERVER / MENTOR / AWARD_MASTER) and "Remove role…". - Call new `user.bulkUpdateRoles({ userIds, addRole?, removeRole? })` mutation. Server-side: only SUPER_ADMIN/PROGRAM_ADMIN, log a `DecisionAuditLog` entry per user changed. - After success, refresh the table and toast "Added MENTOR role to N users; M already had it (no-op)". **3. Mentor-onboarding email** (one-shot): - New email template at `src/lib/email/templates/mentor-onboarding.tsx`: brief welcome, explanation of mentor responsibilities, link to `/mentor`, link to "Switch View" doc/walkthrough. - Trigger: in `user.bulkUpdateRoles` and the existing single-user `updateRoles` mutation, when MENTOR is **newly** added (i.e., wasn't in `roles[]` before this update) → enqueue the email. Idempotent on subsequent edits that keep MENTOR in `roles`. - Add a `User.mentorOnboardingSentAt: DateTime?` column for idempotency. Migration: nullable column, no backfill needed. **4. Fix impersonation banner pointer-events:** - Locate the banner component (grep `Impersonating` / `bg-red-600 fixed top-0`). - Restructure: banner sits in a flex container above the header rather than being `position: fixed` over it. The header height stays unchanged; the banner pushes content down. - Alternative (smaller change): keep `position: fixed` but `pointer-events: none` on the banner div and re-enable `pointer-events: auto` on the inner "Return to Admin" button only. Either fixes the menu intercept. - Pick the simpler diff at impl time; document choice in PR. **5. Banner shows all roles:** - When `session.user.roles.length > 1`, render comma-separated list: "Impersonating Dr. Sophie Laurent (JURY MEMBER, MENTOR)". **6. Standardize the role-switcher (location + presentation):** Today's state: - Header layouts (`role-nav.tsx`) — used by jury, mentor, applicant, observer, award-master — put the user menu **top-right** with role-switcher items inside the dropdown. - Admin layout (`admin-sidebar.tsx`) puts the user menu **bottom-left of the sidebar** with its own duplicate `ROLE_SWITCH_OPTIONS` constant + `switchableRoles` filter (lines 161, 191, 377-401). Two problems: (a) duplicated logic across two files; (b) different physical placement, so a multi-role user has to learn two patterns to find "Switch View". Changes: - **Extract a shared module** at `src/components/layouts/role-switcher.tsx` exporting: - `useRoleSwitcher()` hook returning `{ switchableRoles: Array<{ label, path, icon }>, currentBasePath }`. Both `role-nav.tsx` and `admin-sidebar.tsx` import this. Source of truth for `ROLE_SWITCH_OPTIONS` lives here only. - `RoleSwitcherMenuItems` component — renders the dropdown items (used inside both layouts' user menus). Keeps rendering inline-consistent. - `RoleSwitcherPill` component — a standalone visible button that renders just outside the user-menu dropdown, with label "Switch View" + the icon of the next-best alternate role. Visible only when `switchableRoles.length > 0`. Click opens a small popover listing alternates. - **Place the `RoleSwitcherPill` in a consistent location across all layouts**: top-right of the header, immediately to the LEFT of the notifications bell. For the admin layout (sidebar-based), add a top-right header strip that hosts the pill + notifications + theme toggle, mirroring the other dashboards. (The admin sidebar keeps everything else; just the top-bar is added.) Why top-right: that's where the existing role-nav layouts already put switching/profile actions. Admins gain the pill in the same spot — no learning curve when switching from /admin to /jury. - **Pill behavior:** - Hidden if `switchableRoles.length === 0` (single-role users see nothing — clean default). - Hidden when `isImpersonating` (impersonator UX is already different; the existing impersonation banner with "Return to Admin" handles role-switching for that path). - On hover/focus: shows tooltip "Switch dashboard view". - Keyboard: `Cmd+Shift+V` shortcut opens the popover (nice-to-have; ship if it doesn't add much code). - **Admin sidebar bottom user pill stays** (so admin users can still sign out / open settings from there). The role-switcher items are removed from that menu — they live exclusively in the new pill + the user-dropdown's switch list. (Avoids three places to switch view.) **Acceptance for §D.6:** any signed-in user with `roles.length > 1` sees a "Switch View" pill in the same screen position regardless of which dashboard they're currently in. **Tests** (in PR 6): - `user.getDefaultDashboard` test cases enumerated above (juror+observer with active jury round → /jury; etc.). - `bulkUpdateRoles` adds MENTOR to N users and sends N onboarding emails. - Idempotency: second `bulkUpdateRoles` with same input does NOT resend email. - Impersonation banner does not intercept clicks on user dropdown (Playwright e2e if available). - `RoleSwitcherPill` renders in the top-right of every dashboard for multi-role users; renders nothing for single-role users. - Single shared `useRoleSwitcher` source means changing `ROLE_SWITCH_OPTIONS` updates both layouts simultaneously. --- ## §E. Filter juror preferences to review-only rounds (PR 1) **File:** `src/server/routers/user.ts:1397-1422` (`getOnboardingContext`) **Change:** Query the membership's jury group, including its linked rounds. Filter out memberships where every linked round is LIVE_FINAL or DELIBERATION. Keep memberships where at least one linked round is INTAKE / FILTERING / EVALUATION / SUBMISSION / MENTORING. ```ts const memberships = await ctx.prisma.juryGroupMember.findMany({ where: { userId: ctx.user.id, juryGroup: { rounds: { some: { roundType: { in: ['INTAKE', 'FILTERING', 'EVALUATION', 'SUBMISSION', 'MENTORING'], }, }, }, }, }, include: { juryGroup: { select: { id: true, name: true, defaultMaxAssignments: true } } }, }) ``` (Confirm the relation field name `rounds` on `JuryGroup` during impl — Prisma schema field may be `Round[]` named differently.) **Tests** (in PR 1): - Juror with memberships in (Screening: FILTERING) + (Finals: LIVE_FINAL) → only Screening returned. - Juror with memberships in (Mixed: EVALUATION + LIVE_FINAL) → returned (group has at least one review round). - Juror with only (Finals: LIVE_FINAL) → no memberships returned. **Risk:** very low. Single procedure, additive Prisma filter, easy to revert. --- ## §F. Workspace messaging + files end-to-end ### §F.1 — Server-side path enforcement (PR 2) **Files:** - `src/lib/minio.ts` (add helper) - `src/server/routers/mentor.ts` (`workspaceUploadFile` procedure + presign procedure) - `src/server/services/mentor-workspace.ts` (`uploadFile` service) **New helper** in `src/lib/minio.ts`: ```ts export function generateMentorObjectKey(projectTitle: string, fileName: string): string { return generateObjectKey(projectTitle, fileName, 'mentorship') } ``` This produces `/mentorship/-`, matching the existing project-file scheme. **Procedure changes:** 1. Add a presign procedure (if not present): `mentor.presignWorkspaceUpload({ mentorAssignmentId, fileName, mimeType, size })` → - Loads the `MentorAssignment` + linked `Project` (server-side). - Authorizes: user is the assigned mentor OR a project team member (mentorProcedure for mentors; protectedProcedure with project-team check for applicants). - Constructs `objectKey = generateMentorObjectKey(project.title, fileName)`. - Returns `{ uploadUrl, bucket, objectKey }` — the presigned PUT URL is short-lived (1h). 2. Change `workspaceUploadFile` to accept ONLY `{ uploadToken, description? }` (where `uploadToken` is an opaque value returned by the presign call). The presign procedure stores `{ token → { mentorAssignmentId, fileName, mimeType, size, bucket, objectKey } }` in a short-lived cache (in-memory or Redis if configured, 1h TTL). The upload procedure looks up the token, validates that the user is the same one who called presign, then writes the `MentorFile` row using the cached values. This eliminates any client-controlled path entirely. 3. Mirror the same change for applicant-side uploads to mentor workspace (if a separate procedure exists). **Migration:** Pre-flight — confirm `MentorFile` table is empty (or only test data) in production. If it has any rows, migrate `objectKey`s to the new scheme via a one-shot script; otherwise skip migration. **Tests** (in PR 2): - Presign returns key matching `/mentorship/-` shape. - `workspaceUploadFile` rejects payloads that include `bucket` or `objectKey` (input schema rejects unknown fields via Zod). - Authorization: mentor uploading to a workspace they're NOT assigned to → throws TRPCError UNAUTHORIZED. ### §F.2 — Dashboard message previews (PR 6) **Files:** - New component: `src/components/mentor/recent-messages-card.tsx` - New component: `src/components/applicant/mentor-conversation-card.tsx` - `src/app/(mentor)/mentor/page.tsx` — embed RecentMessagesCard - `src/app/(applicant)/applicant/page.tsx` — embed MentorConversationCard (only render when project has mentorAssignment + workspace enabled) - `src/server/routers/mentor.ts` — new procedure `getRecentMessagesForMentor` (returns last N msgs across all assignments) - `src/server/routers/applicant.ts` — new procedure `getMentorConversationPreview({ projectId })` (returns last 3 msgs + unread count for one project) **Mentor dashboard preview**: - Card title: "Recent Messages" - Shows last 5 unread messages across ALL assignments (sender name + project + first 100 chars + relative timestamp). - Each row links to `/mentor/workspace/` (jumps to that conversation). - "View all" link → `/mentor/messages` (existing or new index — confirm during impl). - Empty state: "No new messages. Your mentees will appear here when they reach out." **Applicant dashboard preview** (only when project has assigned mentor + workspace enabled): - Card title: "Conversation with [Mentor Name]" - Shows last 3 messages (sender name + content + timestamp). - Unread count badge. - "Send a message" inline composer or "Open chat" button → `/applicant/mentor`. - Empty state: "Say hi to your mentor — they're here to help you sharpen your project." **Performance:** both queries use indexed lookups on `MentorMessage(workspaceId, createdAt)`. Add an index migration if not present. **Tests** (in PR 6): - `getRecentMessagesForMentor` returns N most-recent unread messages across assignments. - `getMentorConversationPreview` returns 3 most-recent messages + correct unread count. - Renders gracefully when no assignment / no messages. ### §F.3 — End-to-end verification scenario (covered in §G) A single integration test walking through the full happy path. See §G. --- ## §G. Tests **New test files:** - `tests/unit/mentor-config.test.ts` (PR 3) — Config form persistence per field - `tests/unit/mentor-key-construction.test.ts` (PR 2) — `generateMentorObjectKey` shape + sanitization - `tests/integration/mentor-assignment.test.ts` (PR 4) — manual + auto + bulk + skip - `tests/integration/mentor-round-engine.test.ts` (NEW for PR 3 or PR 5) — pass-through behavior, eligibility variants, advancement - `tests/integration/mentor-workspace.test.ts` (PR 6) — message + file lifecycle, dashboard previews, milestone auto-complete - `tests/unit/jury-preferences-filter.test.ts` (PR 1) — `getOnboardingContext` filter **End-to-end happy path** (`tests/integration/mentor-round-e2e.test.ts`, ships with PR 6): 1. Admin creates a MENTORING round, sets dates + eligibility=requested_only + 14-day deadline. 2. Admin activates round. 3. Project A has `mentoringRequested=true`, project B does not. 4. Round-engine activation: B auto-PASSED (pass-through), A stays PENDING. 5. Admin manually assigns mentor M1 to project A. A flips PENDING → IN_PROGRESS. Mentor + team get assignment notification. 6. M1 sends a message in workspace; team replies. Both messages appear in respective dashboard previews. 7. M1 uploads a file. ObjectKey matches `/mentorship/-...`. Team comments on the file. 8. M1 marks all required milestones complete → assignment.completionStatus = "completed". 9. Admin closes round. A and B both PASSED; A also COMPLETED. This single test covers the operational path the user actually cares about for the upcoming round. --- ## Open questions 1. **`generateMentorObjectKey` — which "project name" field do we pass?** `Project.title` is the obvious choice (it's what `generateObjectKey` for submission files uses). Confirm during impl that there's no team-name-specific field we should prefer. 2. **Does `JuryGroup` have a direct `rounds` Prisma relation?** Spec assumes it; confirm field name during impl. If it's `Round.juryGroupId` only (no back-relation), use a nested `Round` query. 3. **Mentor-onboarding email content** — copy needs writing. Owned by admin, not blocking impl; can ship with placeholder copy and finalize before going live. 4. **`mentor.autoAssignBulk` — does it already skip manually-assigned?** Spec assumes yes; confirm by reading source during PR 4. If no, change is small (add `where: { method: { not: 'MANUAL' } }` to its query). 5. **Pre-flight check on existing mentor files in prod MinIO before §F.1** — must be empty or migrated, not orphaned. Confirm via `prisma db query` against prod read replica before deploying PR 2. ## Risks | Risk | Severity | Mitigation | |------|----------|------------| | Existing mentor files in prod use legacy keys | High if hit | Pre-flight check; migration script ready before deploy | | `bulkUpdateRoles` accidentally removes a critical role | Med | Server-side guard: SUPER_ADMIN cannot be self-demoted; audit log all changes | | Multi-role redirect priority surprises some users | Low | Document the priority order; role switcher exists for override | | AI fallback ordering doesn't match prior AI suggestions | Low | UX banner clearly states fallback is in use; keep logic simple | | Filter on `getOnboardingContext` accidentally hides valid memberships | Low | Tests cover the three cases; ship behind no flag, easy to revert | ## Migration plan - §A: no migration. - §B: no migration. - §C: no migration. - §D: one Prisma migration adding nullable `User.mentorOnboardingSentAt: DateTime?`. No backfill (treat all existing users as not-yet-onboarded; on next role edit, email fires once). - §E: no migration. - §F.1: optional one-shot script to rewrite legacy `MentorFile.objectKey` rows to the new scheme. Only runs if pre-flight check finds rows. The script copies objects to the new key path then updates DB rows in a transaction; old keys remain readable until manual cleanup. - §F.2: optional Prisma index on `MentorMessage(workspaceId, createdAt DESC)` if not present. ## Rollback Each PR independently revertable. PRs 1, 2, 4 ship with no migration → straight git revert. PR 6 has a migration → revert PR + one-line down migration to drop the column. PR 3 has no migration; PR 5 has no migration. ## Acceptance criteria (per phase) **PR 1 (§E):** - Sophie Laurent (member of Screening, Expert, Finals jury groups) sees Screening + Expert preferences only — not Finals. **PR 2 (§F.1):** - New mentor file uploads write to `/mentorship/-` in MinIO. - Removing `bucket` / `objectKey` from a `workspaceUploadFile` call still succeeds. - Old `objectKey` upload payloads now fail Zod validation. **PR 3 (§A):** - All `MentoringConfigSchema` fields are editable from the Config tab. - A draft MENTORING round with no document-promotion configured can pass Launch Readiness without a "File requirements set" check. **PR 4 (§C):** - Admin can manually assign any MENTOR-role user to any project from `/admin/projects/[id]/mentor`. - Round Projects tab "Auto-fill remaining" assigns to all `mentoringRequested=true` projects without a mentor. - Page renders sensibly with no `OPENAI_API_KEY` set (expertise-tag fallback). **PR 5 (§B):** - MENTORING round Overview shows live counts (requested / assigned / unassigned), deadline countdown, mentor pool size, workspace activity totals. - `/admin/mentors` shows real list of MENTOR-role users with current assignments. **PR 6 (§D + §F.2):** - Juror+observer logging in during an active jury round lands on `/jury` (context-aware default). Same user logging in after the round closes lands on `/jury` via static fallback (still highest-priority role they hold). - Mentor+juror with active mentoring assignments and no jury work lands on `/mentor`. - `RoleSwitcherPill` ("Switch View") renders in the top-right of the header on every dashboard for multi-role users, in the same screen position regardless of layout. Single-role users don't see it. - Admin sidebar still has the user pill at the bottom-left for sign-out / settings; role-switcher entries are removed from that menu (live in the new pill instead). - `/admin/members` allows multi-select + "Add MENTOR role to selected" → all selected users get email + role. - Impersonation banner doesn't intercept clicks on the user dropdown. - Mentor `/mentor` dashboard shows "Recent Messages" card; applicant `/applicant` dashboard shows "Conversation with [Mentor]" card.