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

@@ -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.01.0 confidence
analyzedAt DateTime? // When analysis last ran
detectedLang String? // ISO 639-3 code (e.g. 'eng', 'fra', 'und')
langConfidence Float? // 0.01.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)