From e89dca24c3802bddc7c279064e7a34a8eea50289 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 22 May 2026 15:58:16 +0200 Subject: [PATCH 01/12] feat(schema): multi-mentor per team + change-requests + per-assignment email field - MentorAssignment: drop projectId @unique -> composite (projectId, mentorId) - MentorAssignment: add notificationSentAt for idempotent per-team email - MentorFile: add projectId (primary scope); mentorAssignmentId becomes nullable audit FK - MentorChangeRequest: new model + status enum - Migration hand-written with IF EXISTS guards (safe for docker-entrypoint retry) --- .../migration.sql | 78 +++++++ prisma/schema.prisma | 213 +++++++++++------- 2 files changed, 207 insertions(+), 84 deletions(-) create mode 100644 prisma/migrations/20260522155652_multi_mentor_per_team/migration.sql diff --git a/prisma/migrations/20260522155652_multi_mentor_per_team/migration.sql b/prisma/migrations/20260522155652_multi_mentor_per_team/migration.sql new file mode 100644 index 0000000..52f10c7 --- /dev/null +++ b/prisma/migrations/20260522155652_multi_mentor_per_team/migration.sql @@ -0,0 +1,78 @@ +-- Hand-written migration for PR8 (multi-mentor per team). +-- +-- All DDL guarded with IF EXISTS / IF NOT EXISTS so the docker-entrypoint +-- retry loop is safe to re-run. No regex (the 2026-05-07 prod incident was +-- caused by Prisma 6 generating regex-based DDL that Postgres rejected). +-- No BEGIN/COMMIT blocks — Prisma wraps the migration in a transaction. + +-- Phase 1: MentorAssignment — drop unique, add composite, add notification field +ALTER TABLE "MentorAssignment" DROP CONSTRAINT IF EXISTS "MentorAssignment_projectId_key"; +DROP INDEX IF EXISTS "MentorAssignment_projectId_key"; +CREATE UNIQUE INDEX IF NOT EXISTS "MentorAssignment_projectId_mentorId_key" + ON "MentorAssignment"("projectId", "mentorId"); +CREATE INDEX IF NOT EXISTS "MentorAssignment_projectId_idx" + ON "MentorAssignment"("projectId"); +ALTER TABLE "MentorAssignment" ADD COLUMN IF NOT EXISTS "notificationSentAt" TIMESTAMP(3); + +-- Phase 2: MentorFile — re-scope to project (two-phase backfill) +ALTER TABLE "MentorFile" ADD COLUMN IF NOT EXISTS "projectId" TEXT; +UPDATE "MentorFile" mf + SET "projectId" = ma."projectId" + FROM "MentorAssignment" ma + WHERE mf."mentorAssignmentId" = ma."id" + AND mf."projectId" IS NULL; +ALTER TABLE "MentorFile" ALTER COLUMN "projectId" SET NOT NULL; +ALTER TABLE "MentorFile" DROP CONSTRAINT IF EXISTS "MentorFile_projectId_fkey"; +ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_projectId_fkey" + FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; +CREATE INDEX IF NOT EXISTS "MentorFile_projectId_idx" ON "MentorFile"("projectId"); + +-- Phase 2b: Make MentorFile.mentorAssignmentId nullable + switch its FK to SetNull +ALTER TABLE "MentorFile" ALTER COLUMN "mentorAssignmentId" DROP NOT NULL; +ALTER TABLE "MentorFile" DROP CONSTRAINT IF EXISTS "MentorFile_mentorAssignmentId_fkey"; +ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_mentorAssignmentId_fkey" + FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- Phase 3: MentorChangeRequest table +-- Postgres < 14 doesn't support CREATE TYPE ... IF NOT EXISTS, so wrap in a +-- DO block that swallows duplicate_object errors (idempotent for re-runs). +DO $$ BEGIN + CREATE TYPE "MentorChangeRequestStatus" AS ENUM ('PENDING', 'RESOLVED', 'DISMISSED'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +CREATE TABLE IF NOT EXISTS "MentorChangeRequest" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "targetAssignmentId" TEXT, + "requestedByUserId" TEXT, + "reason" TEXT NOT NULL, + "status" "MentorChangeRequestStatus" NOT NULL DEFAULT 'PENDING', + "resolvedByUserId" TEXT, + "resolvedAt" TIMESTAMP(3), + "resolutionNote" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "MentorChangeRequest_pkey" PRIMARY KEY ("id") +); + +ALTER TABLE "MentorChangeRequest" DROP CONSTRAINT IF EXISTS "MentorChangeRequest_projectId_fkey"; +ALTER TABLE "MentorChangeRequest" ADD CONSTRAINT "MentorChangeRequest_projectId_fkey" + FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "MentorChangeRequest" DROP CONSTRAINT IF EXISTS "MentorChangeRequest_targetAssignmentId_fkey"; +ALTER TABLE "MentorChangeRequest" ADD CONSTRAINT "MentorChangeRequest_targetAssignmentId_fkey" + FOREIGN KEY ("targetAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "MentorChangeRequest" DROP CONSTRAINT IF EXISTS "MentorChangeRequest_requestedByUserId_fkey"; +ALTER TABLE "MentorChangeRequest" ADD CONSTRAINT "MentorChangeRequest_requestedByUserId_fkey" + FOREIGN KEY ("requestedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "MentorChangeRequest" DROP CONSTRAINT IF EXISTS "MentorChangeRequest_resolvedByUserId_fkey"; +ALTER TABLE "MentorChangeRequest" ADD CONSTRAINT "MentorChangeRequest_resolvedByUserId_fkey" + FOREIGN KEY ("resolvedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE INDEX IF NOT EXISTS "MentorChangeRequest_projectId_idx" ON "MentorChangeRequest"("projectId"); +CREATE INDEX IF NOT EXISTS "MentorChangeRequest_status_idx" ON "MentorChangeRequest"("status"); +CREATE INDEX IF NOT EXISTS "MentorChangeRequest_targetAssignmentId_idx" ON "MentorChangeRequest"("targetAssignmentId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c30f01b..b448f7b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -118,7 +118,6 @@ enum NotificationChannel { NONE } - enum PartnerVisibility { ADMIN_ONLY JURY_VISIBLE @@ -133,7 +132,6 @@ enum PartnerType { OTHER } - // ============================================================================= // COMPETITION / ROUND ENGINE ENUMS // ============================================================================= @@ -171,7 +169,6 @@ enum ProjectRoundStateValue { WITHDRAWN } - enum CapMode { HARD SOFT @@ -328,8 +325,8 @@ model User { inviteTokenExpiresAt DateTime? // Password reset token - passwordResetToken String? @unique - passwordResetExpiresAt DateTime? + passwordResetToken String? @unique + passwordResetExpiresAt DateTime? // Digest & availability preferences digestFrequency String @default("none") // 'none' | 'daily' | 'weekly' @@ -363,9 +360,9 @@ model User { filteringOverrides FilteringResult[] @relation("FilteringOverriddenBy") // Award overrides - awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy") - awardEligibilityConfirms AwardEligibility[] @relation("AwardEligibilityConfirmer") - awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy") + awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy") + awardEligibilityConfirms AwardEligibility[] @relation("AwardEligibilityConfirmer") + awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy") // In-app notifications notifications InAppNotification[] @relation("UserNotifications") @@ -413,20 +410,24 @@ model User { sessions Session[] // ── Competition/Round architecture relations ── - juryGroupMemberships JuryGroupMember[] - mentorFilesUploaded MentorFile[] @relation("MentorFileUploader") - mentorFilesPromoted MentorFile[] @relation("MentorFilePromoter") - mentorFileComments MentorFileComment[] @relation("MentorFileCommentAuthor") - resultLocksCreated ResultLock[] @relation("ResultLockCreator") - resultUnlockEvents ResultUnlockEvent[] @relation("ResultUnlocker") - submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter") - deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement") + juryGroupMemberships JuryGroupMember[] + mentorFilesUploaded MentorFile[] @relation("MentorFileUploader") + mentorFilesPromoted MentorFile[] @relation("MentorFilePromoter") + mentorFileComments MentorFileComment[] @relation("MentorFileCommentAuthor") + resultLocksCreated ResultLock[] @relation("ResultLockCreator") + resultUnlockEvents ResultUnlockEvent[] @relation("ResultUnlocker") + submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter") + deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement") // AI Ranking - rankingSnapshots RankingSnapshot[] @relation("TriggeredRankingSnapshots") + rankingSnapshots RankingSnapshot[] @relation("TriggeredRankingSnapshots") // Grand-finale logistics - finalistAttendances AttendingMember[] + finalistAttendances AttendingMember[] + + // Mentor change requests + mentorChangeRequestsRequested MentorChangeRequest[] @relation("MentorChangeRequester") + mentorChangeRequestsResolved MentorChangeRequest[] @relation("MentorChangeResolver") @@index([role]) @@index([status]) @@ -629,7 +630,9 @@ model Project { assignments Assignment[] submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull) teamMembers TeamMember[] - mentorAssignment MentorAssignment? + mentorAssignments MentorAssignment[] + mentorFiles MentorFile[] + mentorChangeRequests MentorChangeRequest[] filteringResults FilteringResult[] awardEligibilities AwardEligibility[] awardVotes AwardVote[] @@ -642,12 +645,12 @@ model Project { cohortProjects CohortProject[] // ── Competition/Round architecture relations ── - projectRoundStates ProjectRoundState[] - assignmentIntents AssignmentIntent[] - deliberationVotes DeliberationVote[] - deliberationResults DeliberationResult[] - submissionPromotions SubmissionPromotionEvent[] - notificationLogs NotificationLog[] + projectRoundStates ProjectRoundState[] + assignmentIntents AssignmentIntent[] + deliberationVotes DeliberationVote[] + deliberationResults DeliberationResult[] + submissionPromotions SubmissionPromotionEvent[] + notificationLogs NotificationLog[] // Grand-finale logistics waitlistEntry WaitlistEntry? @@ -699,9 +702,9 @@ model ProjectFile { // Document analysis (optional, populated by document-analyzer service) textPreview String? @db.Text // First ~2000 chars of extracted text - detectedLang String? // ISO 639-3 code (e.g. 'eng', 'fra', 'und') - langConfidence Float? // 0.0–1.0 confidence - analyzedAt DateTime? // When analysis last ran + detectedLang String? // ISO 639-3 code (e.g. 'eng', 'fra', 'und') + langConfidence Float? // 0.0–1.0 confidence + analyzedAt DateTime? // When analysis last ran // MinIO location bucket String @@ -714,7 +717,7 @@ model ProjectFile { replacedById String? // FK to the newer file that replaced this one // ── Competition/Round architecture fields ── - submissionWindowId String? // FK to SubmissionWindow + submissionWindowId String? // FK to SubmissionWindow submissionFileRequirementId String? // FK to SubmissionFileRequirement createdAt DateTime @default(now()) @@ -763,10 +766,10 @@ model Assignment { juryGroupId String? // Relations - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) - juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull) evaluation Evaluation? conflictOfInterest ConflictOfInterest? @@ -1026,12 +1029,12 @@ model NotificationEmailSetting { // ============================================================================= model LearningResource { - id String @id @default(cuid()) - programId String? // null = global resource - title String - description String? @db.Text - contentJson Json? @db.JsonB // BlockNote document structure - accessJson Json? @db.JsonB // Fine-grained access rules + id String @id @default(cuid()) + programId String? // null = global resource + title String + description String? @db.Text + contentJson Json? @db.JsonB // BlockNote document structure + accessJson Json? @db.JsonB // Fine-grained access rules // File storage (for uploaded resources) fileName String? @@ -1270,7 +1273,7 @@ model TeamMember { model MentorAssignment { id String @id @default(cuid()) - projectId String @unique // One mentor per project + projectId String // Team can have multiple mentors; uniqueness enforced via composite below mentorId String // User with MENTOR role or expertise // Assignment tracking @@ -1278,6 +1281,9 @@ model MentorAssignment { assignedAt DateTime @default(now()) assignedBy String? // Admin who assigned + // Per-assignment email idempotency: stamped once the assignment notification email is sent. + notificationSentAt DateTime? + // AI assignment metadata aiConfidenceScore Float? expertiseMatchScore Float? @@ -1304,11 +1310,47 @@ model MentorAssignment { milestoneCompletions MentorMilestoneCompletion[] messages MentorMessage[] files MentorFile[] + changeRequests MentorChangeRequest[] @relation("MentorChangeRequestTarget") + @@unique([projectId, mentorId]) + @@index([projectId]) @@index([mentorId]) @@index([method]) } +// ============================================================================= +// MENTOR CHANGE REQUESTS +// ============================================================================= + +enum MentorChangeRequestStatus { + PENDING + RESOLVED + DISMISSED +} + +model MentorChangeRequest { + id String @id @default(cuid()) + projectId String + targetAssignmentId String? // Optional: a specific co-mentor the request is about + requestedByUserId String? + reason String @db.Text + status MentorChangeRequestStatus @default(PENDING) + resolvedByUserId String? + resolvedAt DateTime? + resolutionNote String? @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + targetAssignment MentorAssignment? @relation("MentorChangeRequestTarget", fields: [targetAssignmentId], references: [id], onDelete: SetNull) + requestedBy User? @relation("MentorChangeRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull) + resolvedBy User? @relation("MentorChangeResolver", fields: [resolvedByUserId], references: [id]) + + @@index([projectId]) + @@index([status]) + @@index([targetAssignmentId]) +} + // ============================================================================= // FILTERING ROUND SYSTEM // ============================================================================= @@ -1443,17 +1485,17 @@ enum AssignmentJobStatus { // ============================================================================= enum RankingTriggerType { - MANUAL // Admin clicked "Run ranking" - AUTO // Auto-triggered by assignment completion + MANUAL // Admin clicked "Run ranking" + AUTO // Auto-triggered by assignment completion RETROACTIVE // Retroactive scan on deployment - QUICK // Quick-rank mode (no preview) + QUICK // Quick-rank mode (no preview) } enum RankingMode { - PREVIEW // Parsed rules shown to admin (not yet applied) + PREVIEW // Parsed rules shown to admin (not yet applied) CONFIRMED // Admin confirmed rules, ranking applied - QUICK // Quick-rank: parse + apply without preview - FORMULA // Formula-only: no LLM, pure math ranking + QUICK // Quick-rank: parse + apply without preview + FORMULA // Formula-only: no LLM, pure math ranking } enum RankingSnapshotStatus { @@ -1470,7 +1512,7 @@ model RankingSnapshot { roundId String // Trigger metadata - triggeredById String? // null = auto-triggered + triggeredById String? // null = auto-triggered triggerType RankingTriggerType @default(MANUAL) // Criteria used @@ -1599,7 +1641,7 @@ model SpecialAward { evaluationRoundId String? juryGroupId String? eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN) - decisionMode String? // "JURY_VOTE" | "ADMIN_DECISION" + decisionMode String? // "JURY_VOTE" | "ADMIN_DECISION" shortlistSize Int @default(10) // Eligibility job tracking @@ -1621,10 +1663,10 @@ model SpecialAward { votes AwardVote[] // Competition/Round architecture relations - competition Competition? @relation(fields: [competitionId], references: [id], onDelete: SetNull) - evaluationRound Round? @relation(fields: [evaluationRoundId], references: [id], onDelete: SetNull) - awardJuryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull) - rounds Round[] @relation("AwardRounds") + competition Competition? @relation(fields: [competitionId], references: [id], onDelete: SetNull) + evaluationRound Round? @relation(fields: [evaluationRoundId], references: [id], onDelete: SetNull) + awardJuryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull) + rounds Round[] @relation("AwardRounds") @@index([programId]) @@index([status]) @@ -1688,12 +1730,12 @@ model AwardJuror { } model AwardVote { - id String @id @default(cuid()) - awardId String - userId String - projectId String - rank Int? // For RANKED mode - justification String? @db.Text + id String @id @default(cuid()) + awardId String + userId String + projectId String + rank Int? // For RANKED mode + justification String? @db.Text votedAt DateTime @default(now()) // Relations @@ -1810,7 +1852,7 @@ model MentorMessage { createdAt DateTime @default(now()) // ── Competition/Round architecture fields ── - workspaceId String? // FK to MentorAssignment (used as workspace) + workspaceId String? // FK to MentorAssignment (used as workspace) senderRole MentorMessageRole? // Relations @@ -2146,9 +2188,9 @@ model Competition { status CompetitionStatus @default(DRAFT) // Competition-wide settings - categoryMode String @default("SHARED") - startupFinalistCount Int @default(3) - conceptFinalistCount Int @default(3) + categoryMode String @default("SHARED") + startupFinalistCount Int @default(3) + conceptFinalistCount Int @default(3) // Notification preferences notifyOnRoundAdvance Boolean @default(true) @@ -2159,7 +2201,7 @@ model Competition { updatedAt DateTime @updatedAt // Relations - program Program @relation(fields: [programId], references: [id], onDelete: Cascade) + program Program @relation(fields: [programId], references: [id], onDelete: Cascade) rounds Round[] juryGroups JuryGroup[] submissionWindows SubmissionWindow[] @@ -2204,10 +2246,10 @@ model Round { updatedAt DateTime @updatedAt // Relations - competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade) - specialAward SpecialAward? @relation("AwardRounds", fields: [specialAwardId], references: [id], onDelete: SetNull) - juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull) - submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull) + competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade) + specialAward SpecialAward? @relation("AwardRounds", fields: [specialAwardId], references: [id], onDelete: SetNull) + juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull) + submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull) projectRoundStates ProjectRoundState[] visibleSubmissionWindows RoundSubmissionVisibility[] assignmentIntents AssignmentIntent[] @@ -2226,7 +2268,7 @@ model Round { filteringResults FilteringResult[] filteringJobs FilteringJob[] assignmentJobs AssignmentJob[] - rankingSnapshots RankingSnapshot[] @relation("RoundRankingSnapshots") + rankingSnapshots RankingSnapshot[] @relation("RoundRankingSnapshots") reminderLogs ReminderLog[] evaluationSummaries EvaluationSummary[] evaluationDiscussions EvaluationDiscussion[] @@ -2272,7 +2314,7 @@ model ProjectRoundState { // ============================================================================= model JuryGroup { - id String @id @default(cuid()) + id String @id @default(cuid()) competitionId String name String slug String @@ -2330,8 +2372,8 @@ model JuryGroupMember { updatedAt DateTime @updatedAt // Relations - juryGroup JuryGroup @relation(fields: [juryGroupId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + juryGroup JuryGroup @relation(fields: [juryGroupId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) assignmentIntents AssignmentIntent[] deliberationVotes DeliberationVote[] deliberationParticipations DeliberationParticipant[] @@ -2369,7 +2411,7 @@ model SubmissionWindow { updatedAt DateTime @updatedAt // Relations - competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade) + competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade) fileRequirements SubmissionFileRequirement[] projectFiles ProjectFile[] rounds Round[] @@ -2403,7 +2445,7 @@ model SubmissionFileRequirement { } model RoundSubmissionVisibility { - id String @id @default(cuid()) + id String @id @default(cuid()) roundId String submissionWindowId String canView Boolean @default(true) @@ -2448,8 +2490,9 @@ model AssignmentIntent { // ============================================================================= model MentorFile { - id String @id @default(cuid()) - mentorAssignmentId String + id String @id @default(cuid()) + projectId String // Primary access scope: files belong to the team + mentorAssignmentId String? // Nullable audit FK: which assignment uploaded; survives mentor drop uploadedByUserId String fileName String @@ -2468,13 +2511,15 @@ model MentorFile { createdAt DateTime @default(now()) // Relations - mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade) - uploadedBy User @relation("MentorFileUploader", fields: [uploadedByUserId], references: [id]) - promotedBy User? @relation("MentorFilePromoter", fields: [promotedByUserId], references: [id]) - promotedFile ProjectFile? @relation("PromotedFromMentorFile", fields: [promotedToFileId], references: [id], onDelete: SetNull) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + mentorAssignment MentorAssignment? @relation(fields: [mentorAssignmentId], references: [id], onDelete: SetNull) + uploadedBy User @relation("MentorFileUploader", fields: [uploadedByUserId], references: [id]) + promotedBy User? @relation("MentorFilePromoter", fields: [promotedByUserId], references: [id]) + promotedFile ProjectFile? @relation("PromotedFromMentorFile", fields: [promotedToFileId], references: [id], onDelete: SetNull) comments MentorFileComment[] promotionEvents SubmissionPromotionEvent[] + @@index([projectId]) @@index([mentorAssignmentId]) @@index([uploadedByUserId]) } @@ -2492,9 +2537,9 @@ model MentorFileComment { updatedAt DateTime @updatedAt // Relations - mentorFile MentorFile @relation(fields: [mentorFileId], references: [id], onDelete: Cascade) - author User @relation("MentorFileCommentAuthor", fields: [authorId], references: [id]) - parentComment MentorFileComment? @relation("CommentThread", fields: [parentCommentId], references: [id], onDelete: Cascade) + mentorFile MentorFile @relation(fields: [mentorFileId], references: [id], onDelete: Cascade) + author User @relation("MentorFileCommentAuthor", fields: [authorId], references: [id]) + parentComment MentorFileComment? @relation("CommentThread", fields: [parentCommentId], references: [id], onDelete: Cascade) replies MentorFileComment[] @relation("CommentThread") @@index([mentorFileId]) @@ -2503,14 +2548,14 @@ model MentorFileComment { } model SubmissionPromotionEvent { - id String @id @default(cuid()) + id String @id @default(cuid()) projectId String roundId String slotKey String sourceType SubmissionPromotionSource sourceFileId String? promotedById String - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) // Relations project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) From a26e486ab53bbbb16bdbea0db50ded4e404d2c65 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 22 May 2026 16:13:28 +0200 Subject: [PATCH 02/12] chore(migration): include manual rollback.sql for PR8 multi-mentor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tested against the 2026-05-07 prod dump: restore → forward → rollback restores the schema to its pre-migration state. Safe to run only BEFORE any project gets a second mentor — re-adding UNIQUE(projectId) will fail otherwise (intended safety signal). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rollback.sql | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 prisma/migrations/20260522155652_multi_mentor_per_team/rollback.sql diff --git a/prisma/migrations/20260522155652_multi_mentor_per_team/rollback.sql b/prisma/migrations/20260522155652_multi_mentor_per_team/rollback.sql new file mode 100644 index 0000000..6110f6f --- /dev/null +++ b/prisma/migrations/20260522155652_multi_mentor_per_team/rollback.sql @@ -0,0 +1,23 @@ +-- PR8 rollback SQL (manual, only safe BEFORE any project has >1 mentor) +-- Reverses 20260522155652_multi_mentor_per_team + +-- MentorChangeRequest: drop new table + enum +DROP TABLE IF EXISTS "MentorChangeRequest"; +DROP TYPE IF EXISTS "MentorChangeRequestStatus"; + +-- MentorFile: drop projectId scope + restore mentorAssignmentId as required Cascade +ALTER TABLE "MentorFile" DROP CONSTRAINT IF EXISTS "MentorFile_projectId_fkey"; +DROP INDEX IF EXISTS "MentorFile_projectId_idx"; +ALTER TABLE "MentorFile" DROP COLUMN IF EXISTS "projectId"; +-- Restoring NOT NULL is safe only if no rows have NULL mentorAssignmentId (true unless multi-mentor assignments were dropped post-migration) +ALTER TABLE "MentorFile" ALTER COLUMN "mentorAssignmentId" SET NOT NULL; +ALTER TABLE "MentorFile" DROP CONSTRAINT IF EXISTS "MentorFile_mentorAssignmentId_fkey"; +ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_mentorAssignmentId_fkey" + FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- MentorAssignment: restore projectId @unique + drop new fields +DROP INDEX IF EXISTS "MentorAssignment_projectId_mentorId_key"; +DROP INDEX IF EXISTS "MentorAssignment_projectId_idx"; +ALTER TABLE "MentorAssignment" DROP COLUMN IF EXISTS "notificationSentAt"; +-- Re-adding UNIQUE will FAIL if any project has >1 mentor (intended safety signal) +ALTER TABLE "MentorAssignment" ADD CONSTRAINT "MentorAssignment_projectId_key" UNIQUE ("projectId"); From 9152ebb399d1c3d1733463511a6a0875fff8c7fd Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 22 May 2026 16:16:28 +0200 Subject: [PATCH 03/12] feat(email): add sendMentorTeamAssignmentEmail for per-team mentor notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fires when a mentor is added to a specific project team — distinct from the one-time onboarding email keyed by User.mentorOnboardingSentAt. Idempotency for this new email is enforced at the call site in Task 4 via MentorAssignment.notificationSentAt. Wrapped in try/catch — never throws. --- src/lib/email.ts | 74 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/lib/email.ts b/src/lib/email.ts index c1da83f..5463a94 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -2752,6 +2752,80 @@ export async function sendMentorOnboardingEmail(email: string, name: string | nu await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html }) } +// ============================================================================= +// Per-team mentor assignment (fires every time a mentor is added to a project) +// ============================================================================= + +function getMentorTeamAssignmentTemplate( + name: string, + projectTitle: string, + workspaceUrl: string, +): EmailTemplate { + const subject = `You've been assigned to a new MOPC project: "${projectTitle}"` + const greeting = name ? `Hi ${name},` : 'Hi there,' + const text = [ + greeting, + '', + `You have been assigned as a mentor to the project "${projectTitle}".`, + '', + 'You may have co-mentors on this team — you can collaborate together in the project workspace.', + '', + `Open the workspace: ${workspaceUrl}`, + '', + 'The MOPC team', + ].join('\n') + + const html = ` + + + +
+
+

New mentor assignment

+
+
+

${name ? `Hi ${escapeHtml(name)},` : 'Hi there,'}

+

You have been assigned as a mentor to the project ${escapeHtml(projectTitle)}.

+

+ Open Project Workspace +

+

+ You may have co-mentors on this team — you can collaborate together in the project workspace. +

+
+
+ Monaco Ocean Protection Challenge +
+
+ + + `.trim() + + return { subject, text, html } +} + +/** + * Send a per-team mentor assignment email. Fires every time a mentor is added + * to a specific project (distinct from the one-time onboarding email). + * Idempotency is enforced at the call site via MentorAssignment.notificationSentAt. + * Never throws — failures are caught and logged. + */ +export async function sendMentorTeamAssignmentEmail( + email: string, + name: string | null, + projectTitle: string, + projectId: string, +): Promise { + try { + const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com' + const workspaceUrl = `${baseUrl.replace(/\/$/, '')}/mentor/workspace/${projectId}` + const template = getMentorTeamAssignmentTemplate(name || '', projectTitle, workspaceUrl) + await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html }) + } catch (error) { + console.error('[sendMentorTeamAssignmentEmail] failed', { email, projectId, error }) + } +} + function getFinalistConfirmationTemplate( name: string, projectTitle: string, From 66110598a02340ac9d8c036291c0c0d86e972810 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 22 May 2026 16:37:37 +0200 Subject: [PATCH 04/12] =?UTF-8?q?refactor(schema-cascade):=20rename=20Proj?= =?UTF-8?q?ect.mentorAssignment=20=E2=86=92=20mentorAssignments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema dropped @unique on MentorAssignment.projectId in PR8 Task 1 → back-relation becomes a list. Mechanical rename of Prisma queries and consumer accessors. Legacy single-mentor callers use [0] with a TODO for PR8 Task 8 to surface the full list. mentor-workspace.ts is left as Task 5. - routers (mentor, project, applicant, finalist, round) and smart-assignment service: include/where/select keys renamed; `mentorAssignment: null` → `mentorAssignments: { none: {} }`; `{ isNot: null }` → `{ some: {} }`. - UI consumers (mentor + applicant pages): `project.mentorAssignment` → `project.mentorAssignments[0]` with TODO markers. - Tests: `findUnique({ projectId })` → `findFirst({ projectId })` since the composite key now requires both projectId+mentorId. MentorFile.create gains the new required projectId. - Workspace endpoints in mentor.ts now guard null mentorAssignmentId until Task 5 re-scopes them to project. - finalist.unconfirm now cascades to ALL active mentor assignments. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(applicant)/applicant/mentor/page.tsx | 9 ++- src/app/(applicant)/applicant/team/page.tsx | 6 +- .../(mentor)/mentor/projects/[id]/page.tsx | 14 ++-- src/server/routers/applicant.ts | 25 ++++--- src/server/routers/finalist.ts | 12 ++-- src/server/routers/mentor.ts | 66 ++++++++++++++----- src/server/routers/project.ts | 47 +++++++------ src/server/routers/round.ts | 2 +- src/server/services/smart-assignment.ts | 4 +- tests/unit/mentor-assignment-ux.test.ts | 12 ++-- tests/unit/mentor-round-stats.test.ts | 1 + 11 files changed, 127 insertions(+), 71 deletions(-) diff --git a/src/app/(applicant)/applicant/mentor/page.tsx b/src/app/(applicant)/applicant/mentor/page.tsx index 289bf71..4f35cf2 100644 --- a/src/app/(applicant)/applicant/mentor/page.tsx +++ b/src/app/(applicant)/applicant/mentor/page.tsx @@ -72,7 +72,10 @@ export default function ApplicantMentorPage() { ) } - const mentor = dashboardData?.project?.mentorAssignment?.mentor + // TODO(PR8 Task 7): show ALL assigned mentors. For now we display only the + // first one until the multi-mentor applicant UI ships. + const primaryAssignment = dashboardData?.project?.mentorAssignments?.[0] ?? null + const mentor = primaryAssignment?.mentor return (
@@ -136,9 +139,9 @@ export default function ApplicantMentorPage() { )} {/* Files */} - {dashboardData?.project?.mentorAssignment?.id && ( + {primaryAssignment?.id && ( )} diff --git a/src/app/(applicant)/applicant/team/page.tsx b/src/app/(applicant)/applicant/team/page.tsx index 0a6528c..c0ad72e 100644 --- a/src/app/(applicant)/applicant/team/page.tsx +++ b/src/app/(applicant)/applicant/team/page.tsx @@ -357,12 +357,12 @@ export default function ApplicantProjectPage() { )}
- {/* Mentor info */} - {project.mentorAssignment?.mentor && ( + {/* Mentor info — TODO(PR8 Task 7): list ALL assigned mentors */} + {project.mentorAssignments?.[0]?.mentor && (

Assigned Mentor

- {project.mentorAssignment.mentor.name} ({project.mentorAssignment.mentor.email}) + {project.mentorAssignments[0].mentor.name} ({project.mentorAssignments[0].mentor.email})

)} diff --git a/src/app/(mentor)/mentor/projects/[id]/page.tsx b/src/app/(mentor)/mentor/projects/[id]/page.tsx index c347c5a..15966a6 100644 --- a/src/app/(mentor)/mentor/projects/[id]/page.tsx +++ b/src/app/(mentor)/mentor/projects/[id]/page.tsx @@ -94,14 +94,18 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { }, }) + // TODO(PR8 Task 9): show co-mentors. For now we pick the first assignment + // to keep tracking + chat working unchanged. + const primaryAssignment = project?.mentorAssignments?.[0] ?? null + // Track view when project loads const trackView = trpc.mentor.trackView.useMutation() useEffect(() => { - if (project?.mentorAssignment?.id) { - trackView.mutate({ mentorAssignmentId: project.mentorAssignment.id }) + if (primaryAssignment?.id) { + trackView.mutate({ mentorAssignmentId: primaryAssignment.id }) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [project?.mentorAssignment?.id]) + }, [primaryAssignment?.id]) if (isLoading) { return @@ -135,7 +139,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD') const otherMembers = project.teamMembers?.filter((m) => m.role !== 'LEAD') || [] - const mentorAssignment = project.mentorAssignment + const mentorAssignment = primaryAssignment const mentorAssignmentId = mentorAssignment?.id const programId = project.program?.id const viewerIsAssignedMentor = @@ -477,7 +481,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { { await sendMessage.mutateAsync({ projectId, message }) }} diff --git a/src/server/routers/applicant.ts b/src/server/routers/applicant.ts index a21e54e..cb88368 100644 --- a/src/server/routers/applicant.ts +++ b/src/server/routers/applicant.ts @@ -1176,7 +1176,7 @@ export const applicantRouter = router({ ], }, include: { - mentorAssignment: { select: { mentorId: true } }, + mentorAssignments: { select: { mentorId: true } }, }, }) @@ -1187,7 +1187,10 @@ export const applicantRouter = router({ }) } - if (!project.mentorAssignment) { + // TODO(PR8 Task 7): notify ALL assigned mentors. For now we notify the + // first one for legacy parity. + const primaryMentorAssignment = project.mentorAssignments[0] ?? null + if (!primaryMentorAssignment) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'No mentor assigned to this project', @@ -1207,9 +1210,9 @@ export const applicantRouter = router({ }, }) - // Notify the mentor + // Notify the (primary) mentor await createNotification({ - userId: project.mentorAssignment.mentorId, + userId: primaryMentorAssignment.mentorId, type: 'MENTOR_MESSAGE', title: 'New Message', message: `${ctx.user.name || 'Team member'} sent a message about "${project.title}"`, @@ -1313,7 +1316,7 @@ export const applicantRouter = router({ submittedBy: { select: { id: true, name: true, email: true }, }, - mentorAssignment: { + mentorAssignments: { include: { mentor: { select: { id: true, name: true, email: true }, @@ -1523,7 +1526,7 @@ export const applicantRouter = router({ select: { id: true, programId: true, - mentorAssignment: { select: { id: true } }, + mentorAssignments: { select: { id: true }, take: 1 }, }, }) @@ -1531,8 +1534,8 @@ export const applicantRouter = router({ return { hasMentor: false, hasEvaluationRounds: false } } - // Check if mentor is assigned - const hasMentor = !!project.mentorAssignment + // Check if mentor is assigned (any active assignment counts) + const hasMentor = project.mentorAssignments.length > 0 // Check if feedback is available — first check admin settings, then fall back to per-round config let hasEvaluationRounds = false @@ -2689,8 +2692,12 @@ export const applicantRouter = router({ }) } - const assignment = await ctx.prisma.mentorAssignment.findUnique({ + // TODO(PR8 Task 7): when multiple mentors are assigned, surface them all + // in the applicant message thread. For now we display the most recently + // assigned (non-dropped) mentor as the "primary". + const assignment = await ctx.prisma.mentorAssignment.findFirst({ where: { projectId: input.projectId }, + orderBy: { assignedAt: 'desc' }, include: { mentor: { select: { id: true, name: true, email: true } } }, }) diff --git a/src/server/routers/finalist.ts b/src/server/routers/finalist.ts index 8834d0e..685fc3c 100644 --- a/src/server/routers/finalist.ts +++ b/src/server/routers/finalist.ts @@ -772,7 +772,8 @@ export const finalistRouter = router({ select: { id: true, title: true, - mentorAssignment: { + mentorAssignments: { + where: { droppedAt: null, completionStatus: { not: 'completed' } }, select: { id: true, completionStatus: true, @@ -796,10 +797,12 @@ export const finalistRouter = router({ data: { status: 'SUPERSEDED' }, }) - // Cascade: drop active mentor assignment (skip if completed or already dropped) - const ma = confirmation.project.mentorAssignment + // Cascade: drop ALL active mentor assignments (skip dropped/completed — + // those were filtered out by the include `where` above). With multi-mentor + // (PR8) we propagate the cascade to every active assignment. + const activeAssignments = confirmation.project.mentorAssignments let cascadedMentorAssignment = false - if (ma && !ma.droppedAt && ma.completionStatus !== 'completed') { + for (const ma of activeAssignments) { await ctx.prisma.mentorAssignment.update({ where: { id: ma.id }, data: { @@ -833,6 +836,7 @@ export const finalistRouter = router({ reason: input.reason, projectId: confirmation.projectId, cascadedMentorAssignment, + cascadedAssignmentCount: activeAssignments.length, }, }) return { ok: true, cascadedMentorAssignment } diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index f818aad..91d75ca 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -82,13 +82,15 @@ export const mentorRouter = router({ const project = await ctx.prisma.project.findUniqueOrThrow({ where: { id: input.projectId }, include: { - mentorAssignment: true, + mentorAssignments: true, }, }) - if (project.mentorAssignment) { + // TODO(PR8 Task 8): surface all mentors. Legacy single-mentor early-return. + const primaryMentor = project.mentorAssignments[0] ?? null + if (primaryMentor) { return { - currentMentor: project.mentorAssignment, + currentMentor: primaryMentor, suggestions: [], source: 'ai' as const, message: 'Project already has a mentor assigned', @@ -222,10 +224,10 @@ export const mentorRouter = router({ // Verify project exists and doesn't have a mentor const project = await ctx.prisma.project.findUniqueOrThrow({ where: { id: input.projectId }, - include: { mentorAssignment: true }, + include: { mentorAssignments: { select: { id: true } } }, }) - if (project.mentorAssignment) { + if (project.mentorAssignments.length > 0) { throw new TRPCError({ code: 'CONFLICT', message: 'Project already has a mentor assigned', @@ -351,13 +353,16 @@ export const mentorRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - // Verify project exists and doesn't have a mentor + // Verify project exists and doesn't already have a mentor. Multi-mentor + // stacking is reserved for explicit admin assignment via `mentor.assign`; + // auto-assignment skips projects that already have at least one mentor + // to avoid double-AI-assignments. const project = await ctx.prisma.project.findUniqueOrThrow({ where: { id: input.projectId }, - include: { mentorAssignment: true }, + include: { mentorAssignments: { select: { id: true } } }, }) - if (project.mentorAssignment) { + if (project.mentorAssignments.length > 0) { throw new TRPCError({ code: 'CONFLICT', message: 'Project already has a mentor assigned', @@ -490,8 +495,12 @@ export const mentorRouter = router({ unassign: adminProcedure .input(z.object({ projectId: z.string() })) .mutation(async ({ ctx, input }) => { - const assignment = await ctx.prisma.mentorAssignment.findUnique({ + // TODO(PR8 Task 8): admin UI should specify which mentor to drop when + // multiple are assigned. Legacy callers pass only projectId — we resolve + // to the most-recent assignment for backward compatibility. + const assignment = await ctx.prisma.mentorAssignment.findFirst({ where: { projectId: input.projectId }, + orderBy: { assignedAt: 'desc' }, include: { mentor: { select: { id: true, name: true } }, project: { select: { id: true, title: true } }, @@ -507,7 +516,7 @@ export const mentorRouter = router({ // Delete assignment await ctx.prisma.mentorAssignment.delete({ - where: { projectId: input.projectId }, + where: { id: assignment.id }, }) // Audit outside transaction so failures don't roll back the unassignment @@ -546,7 +555,7 @@ export const mentorRouter = router({ const projects = await ctx.prisma.project.findMany({ where: { programId: input.programId, - mentorAssignment: null, + mentorAssignments: { none: {} }, wantsMentorship: true, }, select: { id: true }, @@ -716,7 +725,7 @@ export const mentorRouter = router({ where: { roundId: input.roundId, project: { - mentorAssignment: null, + mentorAssignments: { none: {} }, // Only assign mentors to projects whose team has confirmed they will // attend the grand finale. This skips PENDING/DECLINED/EXPIRED/SUPERSEDED // confirmations and any project without a confirmation row at all. @@ -834,7 +843,7 @@ export const mentorRouter = router({ where: { roundId: input.roundId, project: { - mentorAssignment: { isNot: null }, + mentorAssignments: { some: {} }, ...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}), }, }, @@ -906,13 +915,13 @@ export const mentorRouter = router({ ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId, - project: { wantsMentorship: true, mentorAssignment: { isNot: null } }, + project: { wantsMentorship: true, mentorAssignments: { some: {} } }, }, }), ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId, - project: { mentorAssignment: { isNot: null } }, + project: { mentorAssignments: { some: {} } }, }, }), ctx.prisma.mentorMessage.count({ @@ -1107,7 +1116,11 @@ export const mentorRouter = router({ status: true, oceanIssue: true, competitionCategory: true, - mentorAssignment: { + mentorAssignments: { + // TODO(PR8 Task 8): surface all mentors in the activity view. + // For now keep the legacy single-mentor activity row by picking the + // latest-assigned, non-dropped assignment (or the most-recent overall). + orderBy: { assignedAt: 'desc' }, select: { id: true, method: true, @@ -1157,7 +1170,10 @@ export const mentorRouter = router({ const rows = projects.map((p) => { // Treat a dropped mentor assignment as if no mentor is assigned. - const ma = p.mentorAssignment && !p.mentorAssignment.droppedAt ? p.mentorAssignment : null + // TODO(PR8 Task 8): surface all mentors. Legacy shape: pick the most + // recent non-dropped assignment for the activity row. + const firstActive = p.mentorAssignments.find((a) => !a.droppedAt) ?? null + const ma = firstActive const lastMessageAt = ma?.messages[0]?.createdAt ?? null const lastFileAt = ma?.files[0]?.createdAt ?? null const lastActivityAt = [lastMessageAt, lastFileAt] @@ -1279,7 +1295,7 @@ export const mentorRouter = router({ files: { orderBy: { createdAt: 'desc' }, }, - mentorAssignment: { + mentorAssignments: { include: { mentor: { select: { id: true, name: true, email: true }, @@ -2157,6 +2173,12 @@ export const mentorRouter = router({ select: { bucket: true, objectKey: true, fileName: true, mentorAssignmentId: true }, }) if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' }) + // TODO(PR8 Task 5): re-scope workspace access from assignment to project + // so files whose original assignment was dropped (mentorAssignmentId = + // null) remain accessible by the team. + if (!file.mentorAssignmentId) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Orphaned workspace file' }) + } await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId) const url = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900, { downloadFileName: file.fileName }) @@ -2174,6 +2196,10 @@ export const mentorRouter = router({ select: { mentorAssignmentId: true }, }) if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' }) + // TODO(PR8 Task 5): re-scope workspace access from assignment to project. + if (!file.mentorAssignmentId) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Orphaned workspace file' }) + } await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId) try { await workspaceDeleteFileService( @@ -2209,6 +2235,10 @@ export const mentorRouter = router({ if (!file) { throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' }) } + // TODO(PR8 Task 5): re-scope workspace access from assignment to project. + if (!file.mentorAssignmentId) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Orphaned workspace file' }) + } await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId) return workspaceAddFileComment( { diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts index e39c598..4d5b859 100644 --- a/src/server/routers/project.ts +++ b/src/server/routers/project.ts @@ -188,7 +188,7 @@ export const projectRouter = router({ orClauses.push({ assignments: { some: { userId: ctx.user.id } } }) } if (userHasRole(ctx.user, 'MENTOR')) { - orClauses.push({ mentorAssignment: { mentorId: ctx.user.id } }) + orClauses.push({ mentorAssignments: { some: { mentorId: ctx.user.id } } }) } if (userHasRole(ctx.user, 'APPLICANT')) { orClauses.push({ teamMembers: { some: { userId: ctx.user.id } } }) @@ -511,7 +511,7 @@ export const projectRouter = router({ }, orderBy: { joinedAt: 'asc' }, }, - mentorAssignment: { + mentorAssignments: { include: { mentor: { select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true }, @@ -585,14 +585,18 @@ export const projectRouter = router({ })) ) - const mentorWithAvatar = project.mentorAssignment + // TODO(PR8 Task 8): surface all mentors. For now we keep the legacy + // single-mentor shape and just pick the first non-dropped assignment + // so the admin UI keeps rendering without changes. + const primaryAssignment = project.mentorAssignments[0] ?? null + const mentorWithAvatar = primaryAssignment ? { - ...project.mentorAssignment, + ...primaryAssignment, mentor: { - ...project.mentorAssignment.mentor, + ...primaryAssignment.mentor, avatarUrl: await getUserAvatarUrl( - project.mentorAssignment.mentor.profileImageKey, - project.mentorAssignment.mentor.profileImageProvider + primaryAssignment.mentor.profileImageKey, + primaryAssignment.mentor.profileImageProvider ), }, } @@ -1311,7 +1315,7 @@ export const projectRouter = router({ }, orderBy: { joinedAt: 'asc' }, }, - mentorAssignment: { + mentorAssignments: { include: { mentor: { select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true }, @@ -1448,18 +1452,21 @@ export const projectRouter = router({ } }) ), - projectRaw.mentorAssignment - ? (async () => ({ - ...projectRaw.mentorAssignment!, - mentor: { - ...projectRaw.mentorAssignment!.mentor, - avatarUrl: await getUserAvatarUrl( - projectRaw.mentorAssignment!.mentor.profileImageKey, - projectRaw.mentorAssignment!.mentor.profileImageProvider - ), - }, - }))() - : Promise.resolve(null), + // TODO(PR8 Task 8): surface all mentors. Legacy shape — pick the first. + (async () => { + const primaryMa = projectRaw.mentorAssignments[0] ?? null + if (!primaryMa) return null + return { + ...primaryMa, + mentor: { + ...primaryMa.mentor, + avatarUrl: await getUserAvatarUrl( + primaryMa.mentor.profileImageKey, + primaryMa.mentor.profileImageProvider + ), + }, + } + })(), ]) return { diff --git a/src/server/routers/round.ts b/src/server/routers/round.ts index 13522b1..1f5eb13 100644 --- a/src/server/routers/round.ts +++ b/src/server/routers/round.ts @@ -236,7 +236,7 @@ export const roundRouter = router({ where: { roundId: input.roundId, project: { - mentorAssignment: null, + mentorAssignments: { none: {} }, ...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}), }, }, diff --git a/src/server/services/smart-assignment.ts b/src/server/services/smart-assignment.ts index d2dacf2..abb0e67 100644 --- a/src/server/services/smart-assignment.ts +++ b/src/server/services/smart-assignment.ts @@ -670,7 +670,7 @@ export async function getMentorSuggestionsForProject( projectTags: { include: { tag: true }, }, - mentorAssignment: true, + mentorAssignments: true, }, }) @@ -714,7 +714,7 @@ export async function getMentorSuggestionsForProject( for (const mentor of mentors) { // Skip if already assigned to this project - if (project.mentorAssignment?.mentorId === mentor.id) { + if (project.mentorAssignments.some((ma) => ma.mentorId === mentor.id)) { continue } diff --git a/tests/unit/mentor-assignment-ux.test.ts b/tests/unit/mentor-assignment-ux.test.ts index d4fea22..a512a09 100644 --- a/tests/unit/mentor-assignment-ux.test.ts +++ b/tests/unit/mentor-assignment-ux.test.ts @@ -213,12 +213,12 @@ describe('mentor.autoAssignBulkForRound', () => { expect(result.assigned).toBe(1) - const requestedAssigned = await prisma.mentorAssignment.findUnique({ + const requestedAssigned = await prisma.mentorAssignment.findFirst({ where: { projectId: projWithRequest.id }, }) expect(requestedAssigned).not.toBeNull() - const skippedNotAssigned = await prisma.mentorAssignment.findUnique({ + const skippedNotAssigned = await prisma.mentorAssignment.findFirst({ where: { projectId: projWithoutRequest.id }, }) expect(skippedNotAssigned).toBeNull() @@ -291,7 +291,7 @@ describe('mentor.autoAssignBulkForRound', () => { expect(result.assigned).toBe(1) expect(result.skipped).toBe(1) - const stillExisting = await prisma.mentorAssignment.findUnique({ + const stillExisting = await prisma.mentorAssignment.findFirst({ where: { projectId: projAlreadyAssigned.id }, }) expect(stillExisting?.mentorId).toBe(existingMentor.id) // unchanged @@ -377,17 +377,17 @@ describe('mentor.autoAssignBulkForRound', () => { expect(result.assigned).toBe(1) - const confirmedAssigned = await prisma.mentorAssignment.findUnique({ + const confirmedAssigned = await prisma.mentorAssignment.findFirst({ where: { projectId: projConfirmed.id }, }) expect(confirmedAssigned).not.toBeNull() - const pendingAssigned = await prisma.mentorAssignment.findUnique({ + const pendingAssigned = await prisma.mentorAssignment.findFirst({ where: { projectId: projPending.id }, }) expect(pendingAssigned).toBeNull() - const noConfAssigned = await prisma.mentorAssignment.findUnique({ + const noConfAssigned = await prisma.mentorAssignment.findFirst({ where: { projectId: projNoConfirmation.id }, }) expect(noConfAssigned).toBeNull() diff --git a/tests/unit/mentor-round-stats.test.ts b/tests/unit/mentor-round-stats.test.ts index da9920e..4bbc4b4 100644 --- a/tests/unit/mentor-round-stats.test.ts +++ b/tests/unit/mentor-round-stats.test.ts @@ -92,6 +92,7 @@ describe('mentor.getRoundStats', () => { }) await prisma.mentorFile.create({ data: { + projectId: projReqAssigned.id, mentorAssignmentId: a1.id, uploadedByUserId: mentor.id, fileName: 'plan.pdf', From a5ad11a1b501d6544fe13a4ccde62d653f945837 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 22 May 2026 16:38:14 +0200 Subject: [PATCH 05/12] feat(mentor): allow stacking mentors per team; send per-team assignment email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mentor.assign no longer rejects on existing mentor; rejects only on duplicate (projectId, mentorId) via P2002 catch. - After successful create, sendMentorTeamAssignmentEmail fires once and stamps MentorAssignment.notificationSentAt for idempotency. - All existing behavior preserved: audit log, in-app notifications, MENTORING round auto-transition. - mentor.getSuggestions no longer short-circuits when a mentor is already assigned — the suggestions list is now informational and the per-pair unique constraint enforces correctness at assign time. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/routers/mentor.ts | 123 ++++++++++++++++++++++------------- 1 file changed, 77 insertions(+), 46 deletions(-) diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index 91d75ca..f55e74b 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -1,7 +1,8 @@ import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, mentorProcedure, adminProcedure, protectedProcedure } from '../trpc' -import { MentorAssignmentMethod, type PrismaClient } from '@prisma/client' +import { MentorAssignmentMethod, Prisma, type PrismaClient } from '@prisma/client' +import { sendMentorTeamAssignmentEmail } from '@/lib/email' import { getAIMentorSuggestions, getRoundRobinMentor, @@ -86,16 +87,11 @@ export const mentorRouter = router({ }, }) - // TODO(PR8 Task 8): surface all mentors. Legacy single-mentor early-return. + // With multi-mentor (PR8) the project can have several mentors. The + // suggestions endpoint is informational — return whatever AI suggests + // and let `mentor.assign` enforce per-pair uniqueness. We still surface + // an existing primary mentor in the payload so UIs can label it. const primaryMentor = project.mentorAssignments[0] ?? null - if (primaryMentor) { - return { - currentMentor: primaryMentor, - suggestions: [], - source: 'ai' as const, - message: 'Project already has a mentor assigned', - } - } // Detect AI configuration so the UI can label "AI matching unavailable" // when we fall back to algorithmic ranking. An AI error mid-call still @@ -142,7 +138,9 @@ export const mentorRouter = router({ }) return { - currentMentor: null, + // TODO(PR8 Task 8): return the full mentor list. Legacy field kept + // until the admin UI is updated. + currentMentor: primaryMentor, suggestions: enrichedSuggestions.filter((s) => s.mentor !== null), source, message: null, @@ -221,52 +219,62 @@ export const mentorRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - // Verify project exists and doesn't have a mentor + // Verify project exists (multi-mentor: stacking is allowed; duplicate + // (projectId, mentorId) pairs are rejected by the unique constraint + // below). const project = await ctx.prisma.project.findUniqueOrThrow({ where: { id: input.projectId }, - include: { mentorAssignments: { select: { id: true } } }, }) - if (project.mentorAssignments.length > 0) { - throw new TRPCError({ - code: 'CONFLICT', - message: 'Project already has a mentor assigned', - }) - } - // Verify mentor exists const mentor = await ctx.prisma.user.findUniqueOrThrow({ where: { id: input.mentorId }, }) - // Create assignment - const assignment = await ctx.prisma.mentorAssignment.create({ - data: { - projectId: input.projectId, - mentorId: input.mentorId, - method: input.method, - assignedBy: ctx.user.id, - aiConfidenceScore: input.aiConfidenceScore, - expertiseMatchScore: input.expertiseMatchScore, - aiReasoning: input.aiReasoning, - }, - include: { - mentor: { - select: { - id: true, - name: true, - email: true, - expertiseTags: true, + // Create assignment. P2002 on the composite (projectId, mentorId) unique + // constraint means this exact mentor is already on this team — surface a + // friendly error. + let assignment + try { + assignment = await ctx.prisma.mentorAssignment.create({ + data: { + projectId: input.projectId, + mentorId: input.mentorId, + method: input.method, + assignedBy: ctx.user.id, + aiConfidenceScore: input.aiConfidenceScore, + expertiseMatchScore: input.expertiseMatchScore, + aiReasoning: input.aiReasoning, + }, + include: { + mentor: { + select: { + id: true, + name: true, + email: true, + expertiseTags: true, + }, + }, + project: { + select: { + id: true, + title: true, + }, }, }, - project: { - select: { - id: true, - title: true, - }, - }, - }, - }) + }) + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2002' + ) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'This mentor is already assigned to that project.', + }) + } + throw err + } // Audit outside transaction so failures don't roll back the assignment await logAudit({ @@ -281,6 +289,8 @@ export const mentorRouter = router({ mentorId: input.mentorId, mentorName: assignment.mentor.name, method: input.method, + // PR8: per-team assignment (one row per mentor-project pair). + assignmentScope: 'per-team', }, ipAddress: ctx.ip, userAgent: ctx.userAgent, @@ -322,6 +332,27 @@ export const mentorRouter = router({ }, }) + // Send per-team email notification once per assignment row. Idempotency + // is enforced via MentorAssignment.notificationSentAt — a fresh row has + // it null. If the same mentor is later dropped and re-assigned (new row, + // fresh id), a new email is sent — intentional. + if (assignment.notificationSentAt == null && assignment.mentor.email) { + await sendMentorTeamAssignmentEmail( + assignment.mentor.email, + assignment.mentor.name, + assignment.project.title, + input.projectId, + ) + try { + await ctx.prisma.mentorAssignment.update({ + where: { id: assignment.id }, + data: { notificationSentAt: new Date() }, + }) + } catch (e) { + console.error('[Mentor] failed to stamp notificationSentAt (non-fatal):', e) + } + } + // Auto-transition: mark project IN_PROGRESS in any active MENTORING round try { const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({ From 3a1eb149b66e2320b002e874b0b9ec88bf9d3c26 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 22 May 2026 16:53:07 +0200 Subject: [PATCH 06/12] feat(mentor-workspace): re-scope files from assignment to project for team-wide visibility - MentorFile.projectId is the new access boundary; mentorAssignmentId stays as informational audit FK (nullable). - uploadFile derives projectId from the assignment; getFiles takes projectId directly; deleteFile/addFileComment auth checks any mentor on the project OR a project team member. - HMAC upload token now binds to projectId (in addition to assignmentId). - promoteFile reads file.projectId directly (no more mentorAssignment null navigation). - Removes 3 placeholder NOT_FOUND guards added in Task 4. --- src/app/(applicant)/applicant/mentor/page.tsx | 3 +- .../mentor/workspace/[projectId]/page.tsx | 7 +- .../mentor/file-promotion-panel.tsx | 8 +- .../mentor/workspace-files-panel.tsx | 17 ++-- src/lib/mentor-upload-token.ts | 10 +++ src/server/routers/mentor.ts | 80 +++++++++++++------ src/server/services/mentor-workspace.ts | 55 +++++++++---- tests/unit/mentor-upload-token.test.ts | 1 + tests/unit/mentor-workspace-files.test.ts | 7 +- 9 files changed, 133 insertions(+), 55 deletions(-) diff --git a/src/app/(applicant)/applicant/mentor/page.tsx b/src/app/(applicant)/applicant/mentor/page.tsx index 4f35cf2..6a8d4a8 100644 --- a/src/app/(applicant)/applicant/mentor/page.tsx +++ b/src/app/(applicant)/applicant/mentor/page.tsx @@ -139,8 +139,9 @@ export default function ApplicantMentorPage() { )} {/* Files */} - {primaryAssignment?.id && ( + {primaryAssignment?.id && projectId && ( diff --git a/src/app/(mentor)/mentor/workspace/[projectId]/page.tsx b/src/app/(mentor)/mentor/workspace/[projectId]/page.tsx index dfc2473..ddc7a11 100644 --- a/src/app/(mentor)/mentor/workspace/[projectId]/page.tsx +++ b/src/app/(mentor)/mentor/workspace/[projectId]/page.tsx @@ -104,7 +104,10 @@ export default function MentorWorkspaceDetailPage() { {assignment ? ( - + ) : ( @@ -117,7 +120,7 @@ export default function MentorWorkspaceDetailPage() { {assignment ? ( - + ) : ( diff --git a/src/components/mentor/file-promotion-panel.tsx b/src/components/mentor/file-promotion-panel.tsx index 34cb1f4..a9526f4 100644 --- a/src/components/mentor/file-promotion-panel.tsx +++ b/src/components/mentor/file-promotion-panel.tsx @@ -17,7 +17,7 @@ import { FileText, Upload, CheckCircle2, ArrowUp } from 'lucide-react' import { toast } from 'sonner' interface FilePromotionPanelProps { - mentorAssignmentId: string + projectId: string } function formatFileSize(bytes: number): string { @@ -28,14 +28,14 @@ function formatFileSize(bytes: number): string { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] } -export function FilePromotionPanel({ mentorAssignmentId }: FilePromotionPanelProps) { +export function FilePromotionPanel({ projectId }: FilePromotionPanelProps) { const [selectedSlot, setSelectedSlot] = useState('') const utils = trpc.useUtils() const { data: workspaceFiles = [], isLoading: filesLoading } = trpc.mentor.workspaceGetFiles.useQuery( - { mentorAssignmentId }, - { enabled: !!mentorAssignmentId }, + { projectId }, + { enabled: !!projectId }, ) const promoteMutation = trpc.mentor.workspacePromoteFile.useMutation({ diff --git a/src/components/mentor/workspace-files-panel.tsx b/src/components/mentor/workspace-files-panel.tsx index fb8cd9a..d5cce29 100644 --- a/src/components/mentor/workspace-files-panel.tsx +++ b/src/components/mentor/workspace-files-panel.tsx @@ -16,6 +16,13 @@ import { FileText, Upload, Download, Trash2, MessageSquare } from 'lucide-react' import { formatDistanceToNow } from 'date-fns' interface Props { + /** Project the workspace belongs to — drives file list (project-scoped). */ + projectId: string + /** + * One MentorAssignment id on this project — needed only to mint upload tokens + * (the token is signed against the assignment + project pair, but the file + * itself is project-scoped so co-mentors see it). + */ mentorAssignmentId: string /** Set true on the applicant side to label uploads as "Team upload" — purely cosmetic. */ asApplicant?: boolean @@ -29,21 +36,21 @@ function formatSize(bytes: number): string { return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] } -export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props) { +export function WorkspaceFilesPanel({ projectId, mentorAssignmentId, asApplicant }: Props) { const utils = trpc.useUtils() const inputRef = useRef(null) const [uploading, setUploading] = useState(false) const [description, setDescription] = useState('') const { data: files, isLoading } = trpc.mentor.workspaceGetFiles.useQuery( - { mentorAssignmentId }, - { enabled: !!mentorAssignmentId } + { projectId }, + { enabled: !!projectId } ) const presign = trpc.mentor.workspaceGetUploadUrl.useMutation() const recordUpload = trpc.mentor.workspaceUploadFile.useMutation({ onSuccess: () => { - utils.mentor.workspaceGetFiles.invalidate({ mentorAssignmentId }) + utils.mentor.workspaceGetFiles.invalidate({ projectId }) setDescription('') toast.success('File uploaded') }, @@ -51,7 +58,7 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props) const downloadMutation = trpc.mentor.workspaceGetFileDownloadUrl.useMutation() const deleteMutation = trpc.mentor.workspaceDeleteFile.useMutation({ onSuccess: () => { - utils.mentor.workspaceGetFiles.invalidate({ mentorAssignmentId }) + utils.mentor.workspaceGetFiles.invalidate({ projectId }) toast.success('File deleted') }, onError: (e) => toast.error(e.message), diff --git a/src/lib/mentor-upload-token.ts b/src/lib/mentor-upload-token.ts index 06465e4..de74b07 100644 --- a/src/lib/mentor-upload-token.ts +++ b/src/lib/mentor-upload-token.ts @@ -2,6 +2,13 @@ import { createHmac, timingSafeEqual } from 'crypto' export type MentorUploadPayload = { mentorAssignmentId: string + /** + * Project the upload belongs to. Bound at token-issue time so the file's + * project scope can't be tampered with separately from the assignment id. + * Required (no legacy fallback) — tokens live <1h, so any in-flight tokens + * issued before this field was added expire on their own. + */ + projectId: string uploaderUserId: string fileName: string mimeType: string @@ -47,5 +54,8 @@ export function verifyMentorUploadToken(token: string): MentorUploadPayload { if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) { throw new Error('Invalid mentor upload token: expired') } + if (typeof payload.projectId !== 'string' || payload.projectId.length === 0) { + throw new Error('Invalid mentor upload token: missing projectId') + } return payload } diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index f55e74b..f39dc29 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -67,6 +67,42 @@ async function assertWorkspaceAccess( throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this workspace' }) } +/** + * Project-scoped workspace access check (PR8 multi-mentor). + * + * Allowed when the user is either: + * 1) currently assigned as a mentor on this project (droppedAt = null), OR + * 2) a team member of the project. + * + * Also requires at least one active mentor assignment for the project with + * workspaceEnabled = true — meaning the project actually has a live workspace. + * Throws TRPCError on failure. Returns nothing on success. + */ +async function assertProjectWorkspaceAccess( + prisma: PrismaClient, + userId: string, + projectId: string, +): Promise { + const liveMentorAssignment = await prisma.mentorAssignment.findFirst({ + where: { projectId, droppedAt: null, workspaceEnabled: true }, + select: { id: true }, + }) + if (!liveMentorAssignment) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Workspace is not enabled' }) + } + const mentorOnProject = await prisma.mentorAssignment.findFirst({ + where: { projectId, mentorId: userId, droppedAt: null }, + select: { id: true }, + }) + if (mentorOnProject) return + const teamMembership = await prisma.teamMember.findFirst({ + where: { projectId, userId }, + select: { id: true }, + }) + if (teamMembership) return + throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this workspace' }) +} + export const mentorRouter = router({ /** * Get AI-suggested mentor matches for a project @@ -2127,6 +2163,7 @@ export const mentorRouter = router({ const exp = Math.floor(Date.now() / 1000) + 3600 const uploadToken = signMentorUploadToken({ mentorAssignmentId: assignment.id, + projectId: assignment.projectId, uploaderUserId: ctx.user.id, fileName: input.fileName, mimeType: input.mimeType, @@ -2183,14 +2220,17 @@ export const mentorRouter = router({ }), /** - * List files in a workspace. Authorized for the assigned mentor or any - * project team member. + * List files in a project's mentor workspace. Authorized for any mentor + * currently assigned to the project, or any team member of the project. + * + * Project-scoped (PR8): all co-mentors share one file list, and files + * survive even when an originating assignment is later dropped. */ workspaceGetFiles: protectedProcedure - .input(z.object({ mentorAssignmentId: z.string() })) + .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { - await assertWorkspaceAccess(ctx.prisma, ctx.user.id, input.mentorAssignmentId) - return workspaceGetFilesService(input.mentorAssignmentId, ctx.prisma) + await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, input.projectId) + return workspaceGetFilesService(input.projectId, ctx.prisma) }), /** @@ -2201,37 +2241,29 @@ export const mentorRouter = router({ .mutation(async ({ ctx, input }) => { const file = await ctx.prisma.mentorFile.findUnique({ where: { id: input.mentorFileId }, - select: { bucket: true, objectKey: true, fileName: true, mentorAssignmentId: true }, + select: { bucket: true, objectKey: true, fileName: true, projectId: true }, }) if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' }) - // TODO(PR8 Task 5): re-scope workspace access from assignment to project - // so files whose original assignment was dropped (mentorAssignmentId = - // null) remain accessible by the team. - if (!file.mentorAssignmentId) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Orphaned workspace file' }) - } - await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId) + await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId) const url = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900, { downloadFileName: file.fileName }) return { url } }), /** - * Delete a workspace file (uploader or assigned mentor only). + * Delete a workspace file. Authorized for the uploader, any mentor + * currently assigned to the file's project, or any team member of the + * file's project. Final auth check lives in the service. */ workspaceDeleteFile: protectedProcedure .input(z.object({ mentorFileId: z.string() })) .mutation(async ({ ctx, input }) => { const file = await ctx.prisma.mentorFile.findUnique({ where: { id: input.mentorFileId }, - select: { mentorAssignmentId: true }, + select: { projectId: true }, }) if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' }) - // TODO(PR8 Task 5): re-scope workspace access from assignment to project. - if (!file.mentorAssignmentId) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Orphaned workspace file' }) - } - await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId) + await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId) try { await workspaceDeleteFileService( { mentorFileId: input.mentorFileId, userId: ctx.user.id }, @@ -2261,16 +2293,12 @@ export const mentorRouter = router({ .mutation(async ({ ctx, input }) => { const file = await ctx.prisma.mentorFile.findUnique({ where: { id: input.mentorFileId }, - select: { mentorAssignmentId: true }, + select: { projectId: true }, }) if (!file) { throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' }) } - // TODO(PR8 Task 5): re-scope workspace access from assignment to project. - if (!file.mentorAssignmentId) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Orphaned workspace file' }) - } - await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId) + await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId) return workspaceAddFileComment( { mentorFileId: input.mentorFileId, diff --git a/src/server/services/mentor-workspace.ts b/src/server/services/mentor-workspace.ts index 73957d4..10fffa9 100644 --- a/src/server/services/mentor-workspace.ts +++ b/src/server/services/mentor-workspace.ts @@ -152,6 +152,11 @@ export async function markRead( /** * Record a file upload in a workspace. + * + * `workspaceId` is the originating MentorAssignment id (kept on the row as an + * audit-trail FK). We derive the project id from that assignment so the file + * is bound to the project — meaning any co-mentor on the project can see/use + * it, and the row survives if this particular assignment is later dropped. */ export async function uploadFile( params: { @@ -180,6 +185,7 @@ export async function uploadFile( return prisma.mentorFile.create({ data: { + projectId: assignment.projectId, mentorAssignmentId: params.workspaceId, uploadedByUserId: params.uploadedByUserId, fileName: params.fileName, @@ -238,9 +244,6 @@ export async function promoteFile( try { const file = await prisma.mentorFile.findUnique({ where: { id: params.mentorFileId }, - include: { - mentorAssignment: { select: { projectId: true } }, - }, }) if (!file) { @@ -265,7 +268,7 @@ export async function promoteFile( // Create promotion event await tx.submissionPromotionEvent.create({ data: { - projectId: file.mentorAssignment.projectId, + projectId: file.projectId, roundId: params.roundId, slotKey: params.slotKey, sourceType: 'MENTOR_FILE', @@ -281,7 +284,7 @@ export async function promoteFile( entityId: params.mentorFileId, actorId: params.promotedById, detailsJson: { - projectId: file.mentorAssignment.projectId, + projectId: file.projectId, roundId: params.roundId, slotKey: params.slotKey, fileName: file.fileName, @@ -297,7 +300,7 @@ export async function promoteFile( entityType: 'MentorFile', entityId: params.mentorFileId, detailsJson: { - projectId: file.mentorAssignment.projectId, + projectId: file.projectId, slotKey: params.slotKey, }, }) @@ -314,14 +317,17 @@ export async function promoteFile( } /** - * List files for a workspace, newest first, with comment counts and uploader. + * List files for a project, newest first, with comment counts and uploader. + * Project-scoped: every mentor assigned to the project (and every team member) + * sees the same file list, even if some files were uploaded under a now-dropped + * assignment. */ export async function getFiles( - workspaceId: string, + projectId: string, prisma: PrismaClient, ) { return prisma.mentorFile.findMany({ - where: { mentorAssignmentId: workspaceId }, + where: { projectId }, orderBy: { createdAt: 'desc' }, include: { uploadedBy: { select: { id: true, name: true, email: true } }, @@ -331,8 +337,10 @@ export async function getFiles( } /** - * Delete a file. Caller must be either the uploader OR the assigned mentor. - * Removes the MinIO object and the DB row + cascade-deletes comments. + * Delete a file. Caller must be either the uploader, OR any mentor currently + * assigned (not dropped) to the file's project, OR a team member of the + * file's project. Removes the MinIO object and the DB row + cascade-deletes + * comments. */ export async function deleteFile( params: { mentorFileId: string; userId: string }, @@ -341,13 +349,30 @@ export async function deleteFile( ): Promise { const file = await prisma.mentorFile.findUnique({ where: { id: params.mentorFileId }, - include: { mentorAssignment: { select: { mentorId: true } } }, }) if (!file) throw new Error('File not found') const isUploader = file.uploadedByUserId === params.userId - const isMentor = file.mentorAssignment.mentorId === params.userId - if (!isUploader && !isMentor) { - throw new Error('Only the uploader or the assigned mentor can delete this file') + let isAuthorized = isUploader + if (!isAuthorized) { + const mentorAssignment = await prisma.mentorAssignment.findFirst({ + where: { projectId: file.projectId, mentorId: params.userId, droppedAt: null }, + select: { id: true }, + }) + if (mentorAssignment) { + isAuthorized = true + } + } + if (!isAuthorized) { + const teamMembership = await prisma.teamMember.findFirst({ + where: { projectId: file.projectId, userId: params.userId }, + select: { id: true }, + }) + if (teamMembership) { + isAuthorized = true + } + } + if (!isAuthorized) { + throw new Error('Only the uploader, an assigned mentor, or a team member can delete this file') } try { await removeStorageObject(file.bucket, file.objectKey) diff --git a/tests/unit/mentor-upload-token.test.ts b/tests/unit/mentor-upload-token.test.ts index b6ef28f..b31c1ba 100644 --- a/tests/unit/mentor-upload-token.test.ts +++ b/tests/unit/mentor-upload-token.test.ts @@ -6,6 +6,7 @@ import { const samplePayload: MentorUploadPayload = { mentorAssignmentId: 'ma-123', + projectId: 'proj-789', uploaderUserId: 'user-456', fileName: 'doc.pdf', mimeType: 'application/pdf', diff --git a/tests/unit/mentor-workspace-files.test.ts b/tests/unit/mentor-workspace-files.test.ts index b38a8cc..ffc8e6b 100644 --- a/tests/unit/mentor-workspace-files.test.ts +++ b/tests/unit/mentor-workspace-files.test.ts @@ -8,6 +8,7 @@ import { signMentorUploadToken } from '../../src/lib/mentor-upload-token' describe('mentor.workspace files end-to-end', () => { let programId: string + let projectId: string let mentor: { id: string; email: string; role: 'MENTOR' } let outsider: { id: string; email: string; role: 'JURY_MEMBER' } let assignmentId: string @@ -18,6 +19,7 @@ describe('mentor.workspace files end-to-end', () => { const program = await createTestProgram({ name: `mentor-files-${uid()}` }) programId = program.id const project = await createTestProject(programId, { title: 'Test Project' }) + projectId = project.id const m = await createTestUser('MENTOR') userIds.push(m.id) @@ -79,6 +81,7 @@ describe('mentor.workspace files end-to-end', () => { it('rejects workspaceUploadFile with a token whose uploader differs from the caller', async () => { const forged = signMentorUploadToken({ mentorAssignmentId: assignmentId, + projectId, uploaderUserId: 'someone-else', fileName: 'x.pdf', mimeType: 'application/pdf', size: 1, bucket: 'mopc-files', objectKey: 'a/mentorship/0-x.pdf', @@ -94,7 +97,7 @@ describe('mentor.workspace files end-to-end', () => { mentorAssignmentId: assignmentId, fileName: 'b.pdf', mimeType: 'application/pdf', size: 50, }) await caller.workspaceUploadFile({ uploadToken: a.uploadToken }) - const files = await caller.workspaceGetFiles({ mentorAssignmentId: assignmentId }) + const files = await caller.workspaceGetFiles({ projectId }) expect(files.length).toBeGreaterThanOrEqual(2) expect(new Date(files[0].createdAt).getTime()).toBeGreaterThanOrEqual( new Date(files[1].createdAt).getTime(), @@ -104,7 +107,7 @@ describe('mentor.workspace files end-to-end', () => { it('refuses workspaceGetFiles to outsiders', async () => { const caller = createCaller(mentorRouter, outsider) await expect( - caller.workspaceGetFiles({ mentorAssignmentId: assignmentId }) + caller.workspaceGetFiles({ projectId }) ).rejects.toThrow(/FORBIDDEN|not a member/i) }) From ee47c0305ff72eb8e144e5b1be704ee53c88977b Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 22 May 2026 16:59:23 +0200 Subject: [PATCH 07/12] feat(mentor): add change-request procedures + admin email notification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mentor.requestChange: applicants/admins open a PENDING MentorChangeRequest with a reason; one open request per (user, project) enforced - mentor.listChangeRequests: admin-only inbox listing - mentor.resolveChangeRequest: admin marks RESOLVED or DISMISSED with optional resolution note - sendMentorChangeRequestEmail: notifies all SUPER_ADMIN/PROGRAM_ADMIN users when a request is opened (try/catch — never throws) - Mentors are NOT notified of change requests, even after resolution (per design decision in PR8 plan) - Audit log entries for create + resolve; raw reason redacted from audit Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/email.ts | 97 ++++++++++++++ src/server/routers/mentor.ts | 251 ++++++++++++++++++++++++++++++++++- 2 files changed, 346 insertions(+), 2 deletions(-) diff --git a/src/lib/email.ts b/src/lib/email.ts index 5463a94..9c6bdba 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -2826,6 +2826,103 @@ export async function sendMentorTeamAssignmentEmail( } } +// ============================================================================= +// Mentor change requests (PR 8) — admin notification when an applicant or admin +// opens a MentorChangeRequest. Mentors are NOT notified (per design decision). +// ============================================================================= + +function getMentorChangeRequestTemplate( + projectTitle: string, + requesterName: string | null, + reason: string, + adminDashboardUrl: string, +): EmailTemplate { + const subject = `Mentor change request for "${projectTitle}"` + const requesterLabel = requesterName || 'a team member' + const text = [ + 'Hi MOPC admins,', + '', + `A mentor change request has been opened by ${requesterLabel} for the project "${projectTitle}".`, + '', + 'Reason:', + `"${reason}"`, + '', + `Review the request: ${adminDashboardUrl}`, + '', + 'The MOPC team', + ].join('\n') + + const html = ` + + + +
+
+

Mentor change request

+
+
+

Hi MOPC admins,

+

A mentor change request has been opened by ${escapeHtml(requesterLabel)} for the project ${escapeHtml(projectTitle)}.

+
${escapeHtml(reason)}
+

+ Review Request +

+

+ Mentors are not notified of change requests; only admins see this. +

+
+
+ Monaco Ocean Protection Challenge +
+
+ + + `.trim() + + return { subject, text, html } +} + +/** + * Notify all SUPER_ADMIN / PROGRAM_ADMIN users that a mentor change request + * has been opened for a project. Sends one email per recipient. + * Never throws — failures are caught and logged so the calling mutation + * (mentor.requestChange) never fails because of email infrastructure issues. + */ +export async function sendMentorChangeRequestEmail( + adminEmails: string[], + projectTitle: string, + requesterName: string | null, + reason: string, + adminDashboardUrl: string, +): Promise { + try { + if (adminEmails.length === 0) { + console.warn('[sendMentorChangeRequestEmail] no admin recipients; skipping') + return + } + const template = getMentorChangeRequestTemplate( + projectTitle, + requesterName, + reason, + adminDashboardUrl, + ) + await Promise.all( + adminEmails.map((email) => + sendEmail({ + to: email, + subject: template.subject, + text: template.text, + html: template.html, + }).catch((err) => { + console.error('[sendMentorChangeRequestEmail] send failed', { email, err }) + }), + ), + ) + } catch (error) { + console.error('[sendMentorChangeRequestEmail] failed', { error }) + } +} + function getFinalistConfirmationTemplate( name: string, projectTitle: string, diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index f39dc29..1677703 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -1,8 +1,16 @@ import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, mentorProcedure, adminProcedure, protectedProcedure } from '../trpc' -import { MentorAssignmentMethod, Prisma, type PrismaClient } from '@prisma/client' -import { sendMentorTeamAssignmentEmail } from '@/lib/email' +import { + MentorAssignmentMethod, + MentorChangeRequestStatus, + Prisma, + type PrismaClient, +} from '@prisma/client' +import { + sendMentorChangeRequestEmail, + sendMentorTeamAssignmentEmail, +} from '@/lib/email' import { getAIMentorSuggestions, getRoundRobinMentor, @@ -2503,4 +2511,243 @@ export const mentorRouter = router({ })), } }), + + // =========================================================================== + // Mentor change requests (PR8) + // + // Applicants (team members) or admins can open a PENDING change request for + // a project — optionally targeting a specific co-mentor assignment. Admins + // are notified by email; mentors are intentionally NOT notified, even after + // resolution (per design decision in the PR8 plan). + // =========================================================================== + + /** + * Open a new mentor change request. Allowed for: + * • SUPER_ADMIN / PROGRAM_ADMIN (any project), or + * • a team member of the target project. + * + * Rejects with CONFLICT if the same user already has an open (PENDING) request + * for the same project. The raw `reason` is intentionally NOT included in + * audit logs — only its length — for privacy. Email delivery to admins is + * best-effort and never throws. + */ + requestChange: protectedProcedure + .input( + z.object({ + projectId: z.string().min(1), + targetAssignmentId: z.string().min(1).optional(), + reason: z.string().min(10).max(2000), + }), + ) + .mutation(async ({ ctx, input }) => { + const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role) + + // Authorization: admin OR team member of the project + if (!isAdmin) { + const teamMembership = await ctx.prisma.teamMember.findFirst({ + where: { projectId: input.projectId, userId: ctx.user.id }, + select: { id: true }, + }) + if (!teamMembership) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You are not a member of this project', + }) + } + } + + // Load project (also confirms it exists) and validate optional target + const project = await ctx.prisma.project.findUnique({ + where: { id: input.projectId }, + select: { id: true, title: true }, + }) + if (!project) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' }) + } + + if (input.targetAssignmentId) { + const targetAssignment = await ctx.prisma.mentorAssignment.findUnique({ + where: { id: input.targetAssignmentId }, + select: { id: true, projectId: true }, + }) + if (!targetAssignment || targetAssignment.projectId !== input.projectId) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Target assignment does not belong to this project', + }) + } + } + + // One open request per (user, project) + const existingOpen = await ctx.prisma.mentorChangeRequest.findFirst({ + where: { + projectId: input.projectId, + requestedByUserId: ctx.user.id, + status: MentorChangeRequestStatus.PENDING, + }, + select: { id: true }, + }) + if (existingOpen) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'You already have an open mentor change request for this project.', + }) + } + + const created = await ctx.prisma.mentorChangeRequest.create({ + data: { + projectId: input.projectId, + targetAssignmentId: input.targetAssignmentId ?? null, + requestedByUserId: ctx.user.id, + reason: input.reason, + status: MentorChangeRequestStatus.PENDING, + }, + select: { id: true, status: true, createdAt: true }, + }) + + // Notify admins (best-effort, never throw) + try { + const admins = await ctx.prisma.user.findMany({ + where: { + OR: [ + { roles: { has: 'SUPER_ADMIN' } }, + { roles: { has: 'PROGRAM_ADMIN' } }, + ], + status: 'ACTIVE', + }, + select: { email: true }, + }) + const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com' + const adminDashboardUrl = `${baseUrl.replace(/\/$/, '')}/admin/projects/${input.projectId}/mentor` + await sendMentorChangeRequestEmail( + admins.map((a) => a.email), + project.title, + ctx.user.name ?? null, + input.reason, + adminDashboardUrl, + ) + } catch (err) { + // Defense-in-depth: the helper already has its own try/catch + console.error('[mentor.requestChange] notify admins failed:', err) + } + + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'MENTOR_CHANGE_REQUEST_CREATE', + entityType: 'MentorChangeRequest', + entityId: created.id, + detailsJson: { + projectId: input.projectId, + targetAssignmentId: input.targetAssignmentId ?? null, + reasonLength: input.reason.length, + }, + }) + + return created + }), + + /** + * Admin inbox — list MentorChangeRequest rows, optionally filtered by status + * and/or project. PENDING rows are surfaced first; within each status group + * rows are ordered by createdAt desc. No pagination (low-volume admin view). + */ + listChangeRequests: adminProcedure + .input( + z + .object({ + status: z.nativeEnum(MentorChangeRequestStatus).optional(), + projectId: z.string().optional(), + }) + .optional(), + ) + .query(async ({ ctx, input }) => { + const where: Prisma.MentorChangeRequestWhereInput = {} + if (input?.status) where.status = input.status + if (input?.projectId) where.projectId = input.projectId + + const rows = await ctx.prisma.mentorChangeRequest.findMany({ + where, + include: { + project: { select: { id: true, title: true } }, + targetAssignment: { + select: { + id: true, + mentor: { select: { id: true, name: true, email: true } }, + }, + }, + requestedBy: { select: { id: true, name: true, email: true } }, + resolvedBy: { select: { id: true, name: true, email: true } }, + }, + // PENDING first, then RESOLVED/DISMISSED. Within each: newest first. + orderBy: [{ status: 'asc' }, { createdAt: 'desc' }], + }) + + // Enum order is PENDING < RESOLVED < DISMISSED alphabetically — DISMISSED + // is "D" so it sorts before PENDING. Re-sort in JS to guarantee PENDING + // appears first regardless of enum string ordering. + const statusRank: Record = { + [MentorChangeRequestStatus.PENDING]: 0, + [MentorChangeRequestStatus.RESOLVED]: 1, + [MentorChangeRequestStatus.DISMISSED]: 2, + } + return rows.sort((a, b) => { + const sa = statusRank[a.status] - statusRank[b.status] + if (sa !== 0) return sa + return b.createdAt.getTime() - a.createdAt.getTime() + }) + }), + + /** + * Admin resolves a PENDING request as RESOLVED or DISMISSED. Re-resolution + * is rejected. No email or notification is sent to the requester or mentors + * (per PR8 design decision — mentors are never informed of change requests). + */ + resolveChangeRequest: adminProcedure + .input( + z.object({ + id: z.string().min(1), + status: z.enum(['RESOLVED', 'DISMISSED']), + resolutionNote: z.string().max(2000).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const existing = await ctx.prisma.mentorChangeRequest.findUnique({ + where: { id: input.id }, + select: { id: true, status: true, projectId: true }, + }) + if (!existing) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Request not found' }) + } + if (existing.status !== MentorChangeRequestStatus.PENDING) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Request already resolved', + }) + } + + const updated = await ctx.prisma.mentorChangeRequest.update({ + where: { id: existing.id }, + data: { + status: input.status as MentorChangeRequestStatus, + resolvedByUserId: ctx.user.id, + resolvedAt: new Date(), + resolutionNote: input.resolutionNote ?? null, + }, + }) + + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'MENTOR_CHANGE_REQUEST_RESOLVE', + entityType: 'MentorChangeRequest', + entityId: existing.id, + detailsJson: { + status: input.status, + projectId: existing.projectId, + }, + }) + + return updated + }), }) From d440b5f2740edc30cd2435db646e0ce6f0576e81 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 22 May 2026 17:07:11 +0200 Subject: [PATCH 08/12] feat(mentor): show co-mentors on workspace page (PR8 Task 9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds mentor.getProjectMentors({ projectId }) — returns all active MentorAssignment rows for a project, authorized to any mentor on it - Workspace page header surfaces "You + N co-mentor(s): names…" so each mentor knows the team composition without having to ask the admin Co-Authored-By: Claude Opus 4.7 (1M context) --- .../mentor/workspace/[projectId]/page.tsx | 57 ++++++++++++++++++- src/server/routers/mentor.ts | 44 ++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/src/app/(mentor)/mentor/workspace/[projectId]/page.tsx b/src/app/(mentor)/mentor/workspace/[projectId]/page.tsx index ddc7a11..986cd3d 100644 --- a/src/app/(mentor)/mentor/workspace/[projectId]/page.tsx +++ b/src/app/(mentor)/mentor/workspace/[projectId]/page.tsx @@ -1,21 +1,29 @@ 'use client' import { useParams, useRouter } from 'next/navigation' +import { useSession } from 'next-auth/react' import { trpc } from '@/lib/trpc/client' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Skeleton } from '@/components/ui/skeleton' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' import { WorkspaceChat } from '@/components/mentor/workspace-chat' import { FilePromotionPanel } from '@/components/mentor/file-promotion-panel' import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel' -import { ArrowLeft, MessageSquare, FileText, Upload } from 'lucide-react' +import { ArrowLeft, MessageSquare, FileText, Upload, Users } from 'lucide-react' import { toast } from 'sonner' export default function MentorWorkspaceDetailPage() { const params = useParams() const router = useRouter() + const { data: session } = useSession() const projectId = params.projectId as string // Get mentor assignment for this project @@ -27,6 +35,22 @@ export default function MentorWorkspaceDetailPage() { { enabled: !!projectId } ) + // Co-mentor visibility (PR8 multi-mentor): show who else is on the team. + // Gracefully tolerates stale tabs where the caller no longer has access + // (assignment dropped) — query just returns nothing in that case. + const { data: projectMentors } = trpc.mentor.getProjectMentors.useQuery( + { projectId }, + { enabled: !!projectId, retry: false } + ) + + const currentUserId = session?.user?.id + const coMentors = (projectMentors ?? []).filter( + a => a.mentor.id !== currentUserId + ) + const coMentorNames = coMentors.map(a => a.mentor.name ?? 'Unnamed mentor') + const visibleCoMentors = coMentorNames.slice(0, 3) + const hiddenCoMentors = coMentorNames.slice(3) + if (isLoading) { return (
@@ -70,6 +94,37 @@ export default function MentorWorkspaceDetailPage() { {project.teamName && (

{project.teamName}

)} + {coMentors.length > 0 && ( +
+ + + You + {coMentors.length} co-mentor + {coMentors.length === 1 ? '' : 's'}:{' '} + + {visibleCoMentors.join(', ')} + + {hiddenCoMentors.length > 0 && ( + <> + {' '} + + + + + +{hiddenCoMentors.length} more + + + +
+ {hiddenCoMentors.join(', ')} +
+
+
+
+ + )} +
+
+ )}
diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index 1677703..adbfd6a 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -1326,6 +1326,50 @@ export const mentorRouter = router({ return assignments }), + /** + * List all active mentors assigned to a project (PR8 multi-mentor). + * + * Returns one row per active MentorAssignment (droppedAt = null) with the + * mentor's id + name. Used by the mentor workspace page to display the + * co-mentor team so each mentor knows who else they're working with. + * + * Authorization: caller must be an active mentor on the project (or an + * admin via mentorProcedure). Non-assigned mentors get FORBIDDEN. + */ + getProjectMentors: mentorProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ ctx, input }) => { + const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role) + + if (!isAdmin) { + const ownAssignment = await ctx.prisma.mentorAssignment.findFirst({ + where: { + projectId: input.projectId, + mentorId: ctx.user.id, + droppedAt: null, + }, + select: { id: true }, + }) + if (!ownAssignment) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You are not assigned to mentor this project', + }) + } + } + + const assignments = await ctx.prisma.mentorAssignment.findMany({ + where: { projectId: input.projectId, droppedAt: null }, + select: { + id: true, + mentor: { select: { id: true, name: true } }, + }, + orderBy: { assignedAt: 'asc' }, + }) + + return assignments + }), + /** * Get detailed project info for a mentor's assigned project */ From ba115f71a00de7a196ecf0b6e19d5b0c736b35c9 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 22 May 2026 17:09:06 +0200 Subject: [PATCH 09/12] feat(applicant): mentor list + request-change dialog (PR8 Task 7) - /applicant/mentor renders all co-mentors as cards - New "Request a mentor change" dialog opens a free-form reason + optional per-mentor target; calls mentor.requestChange and shows admin-routed confirmation toast - Pending-request guard disables the button until the admin resolves --- src/app/(applicant)/applicant/mentor/page.tsx | 381 +++++++++++------- .../mentor/request-change-dialog.tsx | 179 ++++++++ src/server/routers/applicant.ts | 15 +- 3 files changed, 423 insertions(+), 152 deletions(-) create mode 100644 src/app/(applicant)/applicant/mentor/request-change-dialog.tsx diff --git a/src/app/(applicant)/applicant/mentor/page.tsx b/src/app/(applicant)/applicant/mentor/page.tsx index 6a8d4a8..683208d 100644 --- a/src/app/(applicant)/applicant/mentor/page.tsx +++ b/src/app/(applicant)/applicant/mentor/page.tsx @@ -1,151 +1,230 @@ -'use client' - -import { useSession } from 'next-auth/react' -import { trpc } from '@/lib/trpc/client' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card' -import { Skeleton } from '@/components/ui/skeleton' -import { MentorChat } from '@/components/shared/mentor-chat' -import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel' -import { - MessageSquare, - UserCircle, - FileText, -} from 'lucide-react' - -export default function ApplicantMentorPage() { - const { data: session, status: sessionStatus } = useSession() - const isAuthenticated = sessionStatus === 'authenticated' - - const { data: dashboardData, isLoading: dashLoading } = trpc.applicant.getMyDashboard.useQuery( - undefined, - { enabled: isAuthenticated } - ) - - const projectId = dashboardData?.project?.id - - const { data: mentorMessages, isLoading: messagesLoading } = trpc.applicant.getMentorMessages.useQuery( - { projectId: projectId! }, - { enabled: !!projectId } - ) - - const utils = trpc.useUtils() - const sendMessage = trpc.applicant.sendMentorMessage.useMutation({ - onSuccess: () => { - utils.applicant.getMentorMessages.invalidate({ projectId: projectId! }) - }, - }) - - if (dashLoading) { - return ( -
-
- - -
- -
- ) - } - - if (!projectId) { - return ( -
-
-

Mentor

-
- - - -

No Project

-

- Submit a project first to communicate with your mentor. -

-
-
-
- ) - } - - // TODO(PR8 Task 7): show ALL assigned mentors. For now we display only the - // first one until the multi-mentor applicant UI ships. - const primaryAssignment = dashboardData?.project?.mentorAssignments?.[0] ?? null - const mentor = primaryAssignment?.mentor - - return ( -
- {/* Header */} -
-

- - Mentor Communication -

-

- Chat with your assigned mentor -

-
- - {/* Mentor info */} - {mentor ? ( - - -
- -
-

{mentor.name || 'Mentor'}

-

{mentor.email}

-
-
-
-
- ) : ( - - - -

- No mentor has been assigned to your project yet. - You'll be notified when a mentor is assigned. -

-
-
- )} - - {/* Chat */} - {mentor && ( - - - Messages - - Your conversation history with {mentor.name || 'your mentor'} - - - - { - await sendMessage.mutateAsync({ projectId: projectId!, message }) - }} - isLoading={messagesLoading} - isSending={sendMessage.isPending} - /> - - - )} - - {/* Files */} - {primaryAssignment?.id && projectId && ( - - )} -
- ) -} +'use client' + +import { useState } from 'react' +import { useSession } from 'next-auth/react' +import { format } from 'date-fns' +import { trpc } from '@/lib/trpc/client' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { MentorChat } from '@/components/shared/mentor-chat' +import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel' +import { RequestChangeDialog } from './request-change-dialog' +import { + MessageSquare, + UserCircle, + FileText, + UserCog, +} from 'lucide-react' + +export default function ApplicantMentorPage() { + const { data: session, status: sessionStatus } = useSession() + const isAuthenticated = sessionStatus === 'authenticated' + + const { data: dashboardData, isLoading: dashLoading } = trpc.applicant.getMyDashboard.useQuery( + undefined, + { enabled: isAuthenticated } + ) + + const projectId = dashboardData?.project?.id + + const { data: mentorMessages, isLoading: messagesLoading } = trpc.applicant.getMentorMessages.useQuery( + { projectId: projectId! }, + { enabled: !!projectId } + ) + + const utils = trpc.useUtils() + const sendMessage = trpc.applicant.sendMentorMessage.useMutation({ + onSuccess: () => { + utils.applicant.getMentorMessages.invalidate({ projectId: projectId! }) + }, + }) + + const [isChangeOpen, setIsChangeOpen] = useState(false) + + if (dashLoading) { + return ( +
+
+ + +
+ +
+ ) + } + + if (!projectId) { + return ( +
+
+

Mentor

+
+ + + +

No Project

+

+ Submit a project first to communicate with your mentor. +

+
+
+
+ ) + } + + const assignments = dashboardData?.project?.mentorAssignments ?? [] + const hasMentors = assignments.length > 0 + const primaryAssignment = assignments[0] ?? null + const primaryMentor = primaryAssignment?.mentor + const hasPendingChangeRequest = !!dashboardData?.hasPendingMentorChangeRequest + + const dialogMentors = assignments + .filter((a) => !!a.mentor) + .map((a) => ({ + assignmentId: a.id, + name: a.mentor?.name || a.mentor?.email || 'Mentor', + })) + + const teamHeading = assignments.length > 1 ? 'Your mentor team' : 'Your mentor' + + return ( +
+ {/* Header */} +
+

+ + Mentor Communication +

+

+ {assignments.length > 1 + ? 'Chat with your assigned mentor team' + : 'Chat with your assigned mentor'} +

+
+ + {/* Mentor list */} + {hasMentors ? ( +
+

{teamHeading}

+
+ {assignments.map((assignment) => { + const mentor = assignment.mentor + if (!mentor) return null + const expertise = mentor.expertiseTags ?? [] + return ( + + +
+ +
+

+ {mentor.name || 'Mentor'} +

+

+ {mentor.email} +

+ {assignment.assignedAt && ( +

+ Assigned since {format(new Date(assignment.assignedAt), 'MMM d, yyyy')} +

+ )} +
+
+ {expertise.length > 0 && ( +
+ {expertise.map((tag) => ( + + {tag} + + ))} +
+ )} +
+
+ ) + })} +
+ + {/* Request change action */} +
+

+ {hasPendingChangeRequest + ? "You have a pending mentor change request — admins will follow up soon." + : 'Need a different match? Let the program admins know.'} +

+ +
+
+ ) : ( + + + +

+ No mentor has been assigned to your project yet. + You'll be notified when a mentor is assigned. +

+
+
+ )} + + {/* Chat */} + {primaryMentor && ( + + + Messages + + {assignments.length > 1 + ? 'Your conversation history with your mentor team' + : `Your conversation history with ${primaryMentor.name || 'your mentor'}`} + + + + { + await sendMessage.mutateAsync({ projectId: projectId!, message }) + }} + isLoading={messagesLoading} + isSending={sendMessage.isPending} + /> + + + )} + + {/* Files */} + {primaryAssignment?.id && projectId && ( + + )} + + {/* Request change dialog */} + {projectId && ( + + )} +
+ ) +} diff --git a/src/app/(applicant)/applicant/mentor/request-change-dialog.tsx b/src/app/(applicant)/applicant/mentor/request-change-dialog.tsx new file mode 100644 index 0000000..acfc4b1 --- /dev/null +++ b/src/app/(applicant)/applicant/mentor/request-change-dialog.tsx @@ -0,0 +1,179 @@ +'use client' + +import { useEffect, useState } from 'react' +import { toast } from 'sonner' +import { Loader2 } from 'lucide-react' + +import { trpc } from '@/lib/trpc/client' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' + +const REASON_MIN = 10 +const REASON_MAX = 2000 +const TARGET_ANY = '__any__' + +type MentorOption = { + assignmentId: string + name: string +} + +type RequestChangeDialogProps = { + projectId: string + mentors: MentorOption[] + open: boolean + onOpenChange: (open: boolean) => void +} + +export function RequestChangeDialog({ + projectId, + mentors, + open, + onOpenChange, +}: RequestChangeDialogProps) { + const [reason, setReason] = useState('') + const [target, setTarget] = useState(TARGET_ANY) + const [touched, setTouched] = useState(false) + + const utils = trpc.useUtils() + const requestChange = trpc.mentor.requestChange.useMutation({ + onSuccess: async () => { + toast.success( + "Your request has been sent to the program admins. We'll review it and follow up.", + ) + onOpenChange(false) + // Refresh dashboard so the disabled state for the button updates. + await utils.applicant.getMyDashboard.invalidate() + }, + onError: (error) => { + toast.error(error.message || 'Could not send your request. Please try again.') + }, + }) + + // Reset form when the dialog is closed. + useEffect(() => { + if (!open) { + setReason('') + setTarget(TARGET_ANY) + setTouched(false) + } + }, [open]) + + const trimmedReason = reason.trim() + const reasonTooShort = trimmedReason.length < REASON_MIN + const reasonTooLong = trimmedReason.length > REASON_MAX + const reasonInvalid = reasonTooShort || reasonTooLong + const showReasonError = touched && reasonInvalid + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + setTouched(true) + if (reasonInvalid) return + + requestChange.mutate({ + projectId, + targetAssignmentId: target === TARGET_ANY ? undefined : target, + reason: trimmedReason, + }) + } + + return ( + + + + Request a mentor change + + Share a few details so the program admins can follow up with you. + Your current mentor will not see this message. + + +
+ {mentors.length > 0 && ( +
+ + +

+ Optional. Use this if your request is about one of your co-mentors in particular. +

+
+ )} +
+ +