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)
This commit is contained in:
Matt
2026-05-22 15:58:16 +02:00
parent 3bcbf72ad6
commit e89dca24c3
2 changed files with 207 additions and 84 deletions

View File

@@ -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");

View File

@@ -118,7 +118,6 @@ enum NotificationChannel {
NONE NONE
} }
enum PartnerVisibility { enum PartnerVisibility {
ADMIN_ONLY ADMIN_ONLY
JURY_VISIBLE JURY_VISIBLE
@@ -133,7 +132,6 @@ enum PartnerType {
OTHER OTHER
} }
// ============================================================================= // =============================================================================
// COMPETITION / ROUND ENGINE ENUMS // COMPETITION / ROUND ENGINE ENUMS
// ============================================================================= // =============================================================================
@@ -171,7 +169,6 @@ enum ProjectRoundStateValue {
WITHDRAWN WITHDRAWN
} }
enum CapMode { enum CapMode {
HARD HARD
SOFT SOFT
@@ -428,6 +425,10 @@ model User {
// Grand-finale logistics // Grand-finale logistics
finalistAttendances AttendingMember[] finalistAttendances AttendingMember[]
// Mentor change requests
mentorChangeRequestsRequested MentorChangeRequest[] @relation("MentorChangeRequester")
mentorChangeRequestsResolved MentorChangeRequest[] @relation("MentorChangeResolver")
@@index([role]) @@index([role])
@@index([status]) @@index([status])
} }
@@ -629,7 +630,9 @@ model Project {
assignments Assignment[] assignments Assignment[]
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull) submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
teamMembers TeamMember[] teamMembers TeamMember[]
mentorAssignment MentorAssignment? mentorAssignments MentorAssignment[]
mentorFiles MentorFile[]
mentorChangeRequests MentorChangeRequest[]
filteringResults FilteringResult[] filteringResults FilteringResult[]
awardEligibilities AwardEligibility[] awardEligibilities AwardEligibility[]
awardVotes AwardVote[] awardVotes AwardVote[]
@@ -1270,7 +1273,7 @@ model TeamMember {
model MentorAssignment { model MentorAssignment {
id String @id @default(cuid()) 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 mentorId String // User with MENTOR role or expertise
// Assignment tracking // Assignment tracking
@@ -1278,6 +1281,9 @@ model MentorAssignment {
assignedAt DateTime @default(now()) assignedAt DateTime @default(now())
assignedBy String? // Admin who assigned assignedBy String? // Admin who assigned
// Per-assignment email idempotency: stamped once the assignment notification email is sent.
notificationSentAt DateTime?
// AI assignment metadata // AI assignment metadata
aiConfidenceScore Float? aiConfidenceScore Float?
expertiseMatchScore Float? expertiseMatchScore Float?
@@ -1304,11 +1310,47 @@ model MentorAssignment {
milestoneCompletions MentorMilestoneCompletion[] milestoneCompletions MentorMilestoneCompletion[]
messages MentorMessage[] messages MentorMessage[]
files MentorFile[] files MentorFile[]
changeRequests MentorChangeRequest[] @relation("MentorChangeRequestTarget")
@@unique([projectId, mentorId])
@@index([projectId])
@@index([mentorId]) @@index([mentorId])
@@index([method]) @@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 // FILTERING ROUND SYSTEM
// ============================================================================= // =============================================================================
@@ -2449,7 +2491,8 @@ model AssignmentIntent {
model MentorFile { model MentorFile {
id String @id @default(cuid()) id String @id @default(cuid())
mentorAssignmentId String projectId String // Primary access scope: files belong to the team
mentorAssignmentId String? // Nullable audit FK: which assignment uploaded; survives mentor drop
uploadedByUserId String uploadedByUserId String
fileName String fileName String
@@ -2468,13 +2511,15 @@ model MentorFile {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
// Relations // Relations
mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade) 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]) uploadedBy User @relation("MentorFileUploader", fields: [uploadedByUserId], references: [id])
promotedBy User? @relation("MentorFilePromoter", fields: [promotedByUserId], references: [id]) promotedBy User? @relation("MentorFilePromoter", fields: [promotedByUserId], references: [id])
promotedFile ProjectFile? @relation("PromotedFromMentorFile", fields: [promotedToFileId], references: [id], onDelete: SetNull) promotedFile ProjectFile? @relation("PromotedFromMentorFile", fields: [promotedToFileId], references: [id], onDelete: SetNull)
comments MentorFileComment[] comments MentorFileComment[]
promotionEvents SubmissionPromotionEvent[] promotionEvents SubmissionPromotionEvent[]
@@index([projectId])
@@index([mentorAssignmentId]) @@index([mentorAssignmentId])
@@index([uploadedByUserId]) @@index([uploadedByUserId])
} }