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:
@@ -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");
|
||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user