diff --git a/docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md b/docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md new file mode 100644 index 0000000..ac80507 --- /dev/null +++ b/docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md @@ -0,0 +1,458 @@ +# 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 — priority-based on `roles[]`:** + +Replace single-`role` switch in `src/app/page.tsx` with priority order: + +```ts +const ROLE_DASHBOARD_PRIORITY: Array<[UserRole, Route]> = [ + ['SUPER_ADMIN', '/admin'], + ['PROGRAM_ADMIN', '/admin'], + ['AWARD_MASTER', '/award-master'], + ['JURY_MEMBER', '/jury'], + ['MENTOR', '/mentor'], + ['OBSERVER', '/observer'], + ['APPLICANT', '/applicant'], + ['AUDIENCE', '/audience'], +] +const userRoles = session.user.roles ?? [session.user.role] +const target = ROLE_DASHBOARD_PRIORITY.find(([role]) => userRoles.includes(role))?.[1] +if (target) redirect(target) +``` + +**Decision: priority-based, not "remember last view".** The "remember last view" approach requires a new column on User and adds login-side complexity. Priority is deterministic, easy to explain, and the role-switcher dropdown handles the case where the user wants a different view. Revisit if users complain. + +**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)". + +**Tests** (in PR 6): +- Post-login redirect honors priority for multi-role user. +- `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). + +--- + +## §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):** +- Multi-role user (jury+mentor) lands on `/jury` after login (priority order). Role switcher dropdown shows "Mentor View". +- `/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.