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/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"); 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) diff --git a/src/app/(admin)/admin/projects/[id]/mentor/page.tsx b/src/app/(admin)/admin/projects/[id]/mentor/page.tsx index 5a0b715..4952791 100644 --- a/src/app/(admin)/admin/projects/[id]/mentor/page.tsx +++ b/src/app/(admin)/admin/projects/[id]/mentor/page.tsx @@ -18,6 +18,8 @@ import { Skeleton } from '@/components/ui/skeleton' import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Progress } from '@/components/ui/progress' import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Label } from '@/components/ui/label' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Table, @@ -27,15 +29,35 @@ import { TableHeader, TableRow, } from '@/components/ui/table' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' import { AlertTriangle, ArrowLeft, Bot, Check, + Inbox, Loader2, Search, Sparkles, Users, + UserPlus, } from 'lucide-react' import { getInitials, formatEnumLabel } from '@/lib/utils' @@ -48,14 +70,31 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) { const utils = trpc.useUtils() const [search, setSearch] = useState('') const [pendingMentorId, setPendingMentorId] = useState(null) + const [unassignTarget, setUnassignTarget] = useState<{ + assignmentId: string + mentorName: string + } | null>(null) const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({ id: projectId }) - const { data: candidatesData, isLoading: candidatesLoading } = - trpc.mentor.getCandidates.useQuery( - { projectId }, - { enabled: !!project && !project.mentorAssignment }, + // Already-assigned mentors (full list). Project.get spreads the underlying + // `mentorAssignments` relation so we can read it directly. + const assignedMentorAssignments = useMemo(() => { + if (!project) return [] + // The Prisma relation is included via `...project` spread; type comes + // through the tRPC client. + type Assignment = NonNullable['mentorAssignments'][number] + return ((project as unknown as { mentorAssignments?: Assignment[] }).mentorAssignments ?? []).filter( + (a) => !a.droppedAt, ) + }, [project]) + const assignedMentorIds = useMemo( + () => new Set(assignedMentorAssignments.map((a) => a.mentorId)), + [assignedMentorAssignments], + ) + + const { data: candidatesData, isLoading: candidatesLoading } = + trpc.mentor.getCandidates.useQuery({ projectId }, { enabled: !!project }) const { data: suggestionsData, @@ -63,12 +102,12 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) { refetch: refetchSuggestions, } = trpc.mentor.getSuggestions.useQuery( { projectId, limit: 5 }, - { enabled: !!project && !project.mentorAssignment }, + { enabled: !!project }, ) const assignMutation = trpc.mentor.assign.useMutation({ onSuccess: () => { - toast.success('Mentor assigned') + toast.success('Mentor added') utils.project.get.invalidate({ id: projectId }) utils.mentor.getCandidates.invalidate({ projectId }) utils.mentor.getSuggestions.invalidate({ projectId }) @@ -86,21 +125,31 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) { utils.project.get.invalidate({ id: projectId }) utils.mentor.getCandidates.invalidate({ projectId }) utils.mentor.getSuggestions.invalidate({ projectId }) + setUnassignTarget(null) + }, + onError: (err) => { + toast.error(err.message) + setUnassignTarget(null) }, - onError: (err) => toast.error(err.message), }) const filteredCandidates = useMemo(() => { if (!candidatesData) return [] + const base = candidatesData.candidates.filter((c) => !assignedMentorIds.has(c.id)) const q = search.trim().toLowerCase() - if (!q) return candidatesData.candidates - return candidatesData.candidates.filter((c) => { + if (!q) return base + return base.filter((c) => { const hay = [c.name ?? '', c.email, ...(c.expertiseTags ?? []), c.country ?? ''] .join(' ') .toLowerCase() return hay.includes(q) }) - }, [candidatesData, search]) + }, [candidatesData, search, assignedMentorIds]) + + const filteredSuggestions = useMemo(() => { + if (!suggestionsData) return [] + return suggestionsData.suggestions.filter((s) => !assignedMentorIds.has(s.mentorId)) + }, [suggestionsData, assignedMentorIds]) if (projectLoading) return if (!project) { @@ -113,7 +162,6 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) { ) } - const hasMentor = !!project.mentorAssignment const teamSize = project.teamMembers?.length ?? 0 const aiSource = suggestionsData?.source ?? 'ai' @@ -206,320 +254,632 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) { + {/* ─── Pending Change Requests ─── */} + + {/* ─── Currently Assigned ─── */} Currently Assigned + + {assignedMentorAssignments.length === 0 + ? 'No mentors assigned yet' + : `${assignedMentorAssignments.length} mentor${ + assignedMentorAssignments.length === 1 ? '' : 's' + } on this team`} + - {hasMentor ? ( -
-
- - - {getInitials( - project.mentorAssignment!.mentor.name || - project.mentorAssignment!.mentor.email, - )} - - -
- - {project.mentorAssignment!.mentor.name || 'Unnamed'} - -

- {project.mentorAssignment!.mentor.email} -

- {project.mentorAssignment!.mentor.expertiseTags && - project.mentorAssignment!.mentor.expertiseTags.length > 0 && ( -
- {project.mentorAssignment!.mentor.expertiseTags - .slice(0, 5) - .map((tag: string) => ( - - {tag} - - ))} -
- )} -
-
-
- - {project.mentorAssignment!.method.replace(/_/g, ' ')} - - -
+ {assignedMentorAssignments.length === 0 ? ( +
+ +

+ No mentors assigned yet — add one below. +

) : ( -

- No mentor assigned yet — pick one below. -

+
    + {assignedMentorAssignments.map((a) => { + const m = a.mentor + const tags = m.expertiseTags ?? [] + return ( +
  • +
    + + + {getInitials(m.name || m.email)} + + +
    + + {m.name || 'Unnamed'} + +

    {m.email}

    + {tags.length > 0 && ( +
    + {tags.slice(0, 5).map((tag: string) => ( + + {tag} + + ))} + {tags.length > 5 && ( + + +{tags.length - 5} + + )} +
    + )} +

    + Assigned{' '} + {new Date(a.assignedAt).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} +

    +
    +
    +
    + + {a.method.replace(/_/g, ' ')} + + +
    +
  • + ) + })} +
)} - {/* ─── Pick a Mentor ─── */} - {!hasMentor && ( - - - Pick a Mentor - - Browse all eligible mentors or use AI to surface the best fits. - - - - - - - Manual Picker - - - AI Suggestions - - + {/* ─── Add a Mentor ─── */} + + + + + Add a Mentor + + + Stack additional mentors on this team. Browse all eligible mentors or use AI to surface the best fits. + + + + + + + Manual Picker + + + AI Suggestions + + - -
- - setSearch(e.target.value)} - placeholder="Search by name, email, country, or expertise tag…" - className="pl-9" - /> + +
+ + setSearch(e.target.value)} + placeholder="Search by name, email, country, or expertise tag…" + className="pl-9" + /> +
+ {candidatesLoading ? ( +
+ {[1, 2, 3].map((i) => ( + + ))}
- {candidatesLoading ? ( -
- {[1, 2, 3].map((i) => ( - - ))} -
- ) : filteredCandidates.length === 0 ? ( -
- No matching mentors. Try a different search. -
- ) : ( -
- - - - Mentor - Expertise - Country - Load - Overlap - + ) : filteredCandidates.length === 0 ? ( +
+ {assignedMentorIds.size > 0 && search.trim() === '' + ? 'All eligible mentors are already assigned.' + : 'No matching mentors. Try a different search.'} +
+ ) : ( +
+
+ + + Mentor + Expertise + Country + Load + Overlap + + + + + {filteredCandidates.map((c) => ( + + +
{c.name ?? 'Unnamed'}
+
{c.email}
+
+ +
+ {c.expertiseTags.slice(0, 4).map((tag) => ( + + {tag} + + ))} + {c.expertiseTags.length > 4 && ( + + +{c.expertiseTags.length - 4} + + )} +
+
+ {c.country ?? '—'} + + {c.currentAssignments} + {c.maxAssignments != null ? `/${c.maxAssignments}` : ''} + + + = 0.5 + ? 'default' + : c.overlapScore > 0 + ? 'secondary' + : 'outline' + } + className="text-xs tabular-nums" + > + {Math.round(c.overlapScore * 100)}% + + + + +
- - - {filteredCandidates.map((c) => ( - - -
{c.name ?? 'Unnamed'}
-
{c.email}
-
- -
- {c.expertiseTags.slice(0, 4).map((tag) => ( - - {tag} - - ))} - {c.expertiseTags.length > 4 && ( - - +{c.expertiseTags.length - 4} - - )} -
-
- {c.country ?? '—'} - - {c.currentAssignments} - {c.maxAssignments != null ? `/${c.maxAssignments}` : ''} - - - = 0.5 - ? 'default' - : c.overlapScore > 0 - ? 'secondary' - : 'outline' - } - className="text-xs tabular-nums" - > - {Math.round(c.overlapScore * 100)}% - - - - - -
- ))} -
-
-
- )} -
- - - {aiSource === 'fallback' && ( -
- -
-

AI matching unavailable

-

- Showing expertise-tag overlap instead. Configure{' '} - OPENAI_API_KEY to enable AI matching. -

-
-
- )} -
- + ))} + +
- {suggestionsLoading ? ( -
- {[1, 2, 3].map((i) => ( - - ))} + )} + + + + {aiSource === 'fallback' && ( +
+ +
+

AI matching unavailable

+

+ Showing expertise-tag overlap instead. Configure{' '} + OPENAI_API_KEY to enable AI matching. +

- ) : !suggestionsData || suggestionsData.suggestions.length === 0 ? ( -

- No suggestions available. -

- ) : ( -
- {suggestionsData.suggestions.map((s, i) => ( -
-
-
- - - {s.mentor - ? getInitials(s.mentor.name || s.mentor.email) - : '?'} - - - {i === 0 && ( -
- 1 -
- )} -
-
-
-

{s.mentor?.name || 'Unnamed'}

- - {' '} - {aiSource === 'ai' ? 'AI' : 'Tag overlap'} - +
+ )} +
+ +
+ {suggestionsLoading ? ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ) : filteredSuggestions.length === 0 ? ( +

+ {assignedMentorIds.size > 0 + ? 'All top suggestions are already assigned.' + : 'No suggestions available.'} +

+ ) : ( +
+ {filteredSuggestions.map((s, i) => ( +
+
+
+ + + {s.mentor + ? getInitials(s.mentor.name || s.mentor.email) + : '?'} + + + {i === 0 && ( +
+ 1
-

{s.mentor?.email}

- {s.mentor?.expertiseTags && s.mentor.expertiseTags.length > 0 && ( -
- {s.mentor.expertiseTags.slice(0, 5).map((tag) => ( - - {tag} - - ))} -
- )} -
-
- Confidence: - - - {Math.round(s.confidenceScore * 100)}% - -
-
- - Expertise Match: - - - - {Math.round(s.expertiseMatchScore * 100)}% - -
-
- {s.reasoning && ( -

- "{s.reasoning}" -

- )} -
-
- +
+
+
+

{s.mentor?.name || 'Unnamed'}

+ + {' '} + {aiSource === 'ai' ? 'AI' : 'Tag overlap'} + +
+

{s.mentor?.email}

+ {s.mentor?.expertiseTags && s.mentor.expertiseTags.length > 0 && ( +
+ {s.mentor.expertiseTags.slice(0, 5).map((tag) => ( + + {tag} + + ))} +
+ )} +
+
+ Confidence: + + + {Math.round(s.confidenceScore * 100)}% + +
+
+ + Expertise Match: + + + + {Math.round(s.expertiseMatchScore * 100)}% + +
+
+ {s.reasoning && ( +

+ "{s.reasoning}" +

+ )} +
- ))} -
- )} - - - - - )} + +
+ ))} +
+ )} + + + + + + {/* ─── Unassign confirm ─── */} + { + if (!open) setUnassignTarget(null) + }} + > + + + Unassign mentor? + + {unassignTarget + ? `Remove ${unassignTarget.mentorName} from this team? Other co-mentors will remain.` + : ''} + + + + + Cancel + + { + e.preventDefault() + if (!unassignTarget) return + unassignMutation.mutate({ assignmentId: unassignTarget.assignmentId }) + }} + disabled={unassignMutation.isPending} + > + {unassignMutation.isPending ? ( + + ) : ( + 'Unassign' + )} + + + +
) } +// ───────────────────────────────────────────────────────────────────────────── +// Pending Change Requests panel +// ───────────────────────────────────────────────────────────────────────────── + +function PendingChangeRequestsPanel({ projectId }: { projectId: string }) { + const utils = trpc.useUtils() + const { data: requests, isLoading } = trpc.mentor.listChangeRequests.useQuery({ + projectId, + status: 'PENDING', + }) + + const [resolveTarget, setResolveTarget] = useState<{ + id: string + status: 'RESOLVED' | 'DISMISSED' + requesterName: string + } | null>(null) + const [resolutionNote, setResolutionNote] = useState('') + + const resolveMutation = trpc.mentor.resolveChangeRequest.useMutation({ + onSuccess: (_, variables) => { + toast.success( + `Request marked ${variables.status === 'RESOLVED' ? 'resolved' : 'dismissed'}`, + ) + utils.mentor.listChangeRequests.invalidate() + setResolveTarget(null) + setResolutionNote('') + }, + onError: (err) => toast.error(err.message), + }) + + if (isLoading) { + return ( + + + + + Pending change requests + + + + + + + ) + } + + if (!requests || requests.length === 0) { + return null + } + + return ( + <> + + + + + Pending change requests + + {requests.length} + + + + Team members or mentors have asked admin to change a mentor on this team. + + + +
    + {requests.map((r) => ( + + setResolveTarget({ + id: r.id, + status, + requesterName: + r.requestedBy?.name ?? r.requestedBy?.email ?? 'Unknown', + }) + } + /> + ))} +
+
+
+ + { + if (!open) { + setResolveTarget(null) + setResolutionNote('') + } + }} + > + + + + {resolveTarget?.status === 'RESOLVED' + ? 'Mark request resolved' + : 'Dismiss request'} + + + {resolveTarget?.status === 'RESOLVED' + ? `You've taken action on the request from ${resolveTarget?.requesterName}. Optionally add a note explaining what was done.` + : `Close the request from ${resolveTarget?.requesterName} without action. Optionally add a note explaining why.`} + + +
+ +