Files
MOPC-Portal/docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md
Matt 305b35f3a8 docs: mentor round readiness design spec
Comprehensive spec for upcoming MENTORING round (R6): config form
completeness, mentor-specific admin views, manual + auto-fill
assignment UX, multi-role juror→mentor flow, juror preferences filter,
workspace messaging/file UX with server-side path enforcement, and
test coverage. Phased into six independently-shippable PRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:54:50 +02:00

28 KiB

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 — priority-based on roles[]:

Replace single-role switch in src/app/page.tsx with priority order:

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.

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:

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 objectKeys 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):

  • 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.