# 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 `<projectName>/mentorship/<file>` 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 `<sanitizedProjectName>/mentorship/<timestamp>-<sanitizedFileName>` — 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 `<sanitizedProjectName>/mentorship/<timestamp>-<sanitizedFileName>`, 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 `<projectName>/mentorship/<timestamp>-<file>` 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/<projectId>` (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 `<projectA-title>/mentorship/<timestamp>-...`. 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 `<projectName>/mentorship/<timestamp>-<file>` 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.
