From a66bd728cded2cec442e347497b9b603685dc5bf Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 10 Jun 2026 18:04:25 +0200 Subject: [PATCH] feat(finale): schema for phases, audience windows, favorite votes, notes, reveal Co-Authored-By: Claude Opus 4.8 (1M context) --- .../migration.sql | 104 + prisma/schema.prisma | 6089 +++++++++-------- 2 files changed, 3196 insertions(+), 2997 deletions(-) create mode 100644 prisma/migrations/20260610160500_grand_finale_ceremony/migration.sql diff --git a/prisma/migrations/20260610160500_grand_finale_ceremony/migration.sql b/prisma/migrations/20260610160500_grand_finale_ceremony/migration.sql new file mode 100644 index 0000000..a0ba07f --- /dev/null +++ b/prisma/migrations/20260610160500_grand_finale_ceremony/migration.sql @@ -0,0 +1,104 @@ +-- CreateEnum +CREATE TYPE "LivePhase" AS ENUM ('ON_DECK', 'PRESENTING', 'QA', 'SCORING'); + +-- CreateEnum +CREATE TYPE "AudiencePhase" AS ENUM ('CLOSED', 'OPEN'); + +-- AlterTable +ALTER TABLE "LiveProgressCursor" ADD COLUMN "overrideSlide" TEXT, +ADD COLUMN "phaseDurationSeconds" INTEGER, +ADD COLUMN "phasePausedAccumMs" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "phasePausedAt" TIMESTAMP(3), +ADD COLUMN "phaseStartedAt" TIMESTAMP(3), +ADD COLUMN "projectPhase" "LivePhase" NOT NULL DEFAULT 'ON_DECK', +ADD COLUMN "timingLogJson" JSONB; + +-- AlterTable +ALTER TABLE "LiveVote" ADD COLUMN "comment" TEXT; + +-- AlterTable +ALTER TABLE "LiveVotingSession" ADD COLUMN "allowOverallFavorite" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "audiencePhase" "AudiencePhase" NOT NULL DEFAULT 'CLOSED', +ADD COLUMN "audienceWindowClosesAt" TIMESTAMP(3), +ADD COLUMN "audienceWindowKey" TEXT, +ADD COLUMN "audienceWindowOpenedAt" TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "AudienceFavoriteVote" ( + "id" TEXT NOT NULL, + "sessionId" TEXT NOT NULL, + "windowKey" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "audienceVoterId" TEXT NOT NULL, + "ipAddress" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AudienceFavoriteVote_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LiveNote" ( + "id" TEXT NOT NULL, + "roundId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "content" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LiveNote_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RevealState" ( + "id" TEXT NOT NULL, + "sessionId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'DRAFT', + "stepsJson" JSONB NOT NULL, + "currentStepIndex" INTEGER NOT NULL DEFAULT -1, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "RevealState_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "AudienceFavoriteVote_sessionId_windowKey_ipAddress_idx" ON "AudienceFavoriteVote"("sessionId", "windowKey", "ipAddress"); + +-- CreateIndex +CREATE INDEX "AudienceFavoriteVote_sessionId_windowKey_projectId_idx" ON "AudienceFavoriteVote"("sessionId", "windowKey", "projectId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AudienceFavoriteVote_sessionId_windowKey_audienceVoterId_key" ON "AudienceFavoriteVote"("sessionId", "windowKey", "audienceVoterId"); + +-- CreateIndex +CREATE INDEX "LiveNote_userId_idx" ON "LiveNote"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "LiveNote_roundId_projectId_userId_key" ON "LiveNote"("roundId", "projectId", "userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "RevealState_sessionId_key" ON "RevealState"("sessionId"); + +-- AddForeignKey +ALTER TABLE "AudienceFavoriteVote" ADD CONSTRAINT "AudienceFavoriteVote_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "LiveVotingSession"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AudienceFavoriteVote" ADD CONSTRAINT "AudienceFavoriteVote_audienceVoterId_fkey" FOREIGN KEY ("audienceVoterId") REFERENCES "AudienceVoter"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AudienceFavoriteVote" ADD CONSTRAINT "AudienceFavoriteVote_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LiveNote" ADD CONSTRAINT "LiveNote_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LiveNote" ADD CONSTRAINT "LiveNote_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LiveNote" ADD CONSTRAINT "LiveNote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RevealState" ADD CONSTRAINT "RevealState_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "LiveVotingSession"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 12219bb..6c34b28 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,2997 +1,3092 @@ -// ============================================================================= -// MOPC Platform - Prisma Schema -// ============================================================================= -// This schema defines the database structure for the Monaco Ocean Protection -// Challenge jury voting platform. - -generator client { - provider = "prisma-client-js" - binaryTargets = ["native", "windows", "linux-musl-openssl-3.0.x"] -} - -datasource db { - provider = "postgresql" - // connection_limit and pool_timeout are set via query params in DATABASE_URL: - // ?connection_limit=10&pool_timeout=30 - // Defaults: connection_limit = num_cpus * 2 + 1, pool_timeout = 10s. - // Override in .env for production to prevent connection exhaustion. - url = env("DATABASE_URL") -} - -// ============================================================================= -// ENUMS -// ============================================================================= - -enum UserRole { - SUPER_ADMIN - PROGRAM_ADMIN - JURY_MEMBER - MENTOR - OBSERVER - APPLICANT - AUDIENCE -} - -enum UserStatus { - NONE - INVITED - ACTIVE - SUSPENDED -} - -enum ProgramStatus { - DRAFT - ACTIVE - ARCHIVED -} - -enum ProjectStatus { - SUBMITTED - ELIGIBLE - ASSIGNED - SEMIFINALIST - FINALIST - REJECTED -} - -enum EvaluationStatus { - NOT_STARTED - DRAFT - SUBMITTED - LOCKED -} - -enum AssignmentMethod { - MANUAL - BULK - AI_SUGGESTED - AI_AUTO - ALGORITHM -} - -enum FileType { - EXEC_SUMMARY - PRESENTATION - VIDEO - OTHER - BUSINESS_PLAN - VIDEO_PITCH - SUPPORTING_DOC -} - -enum SubmissionSource { - MANUAL - CSV - NOTION - TYPEFORM - PUBLIC_FORM -} - -enum SettingType { - STRING - NUMBER - BOOLEAN - JSON - SECRET -} - -enum SettingCategory { - AI - BRANDING - EMAIL - STORAGE - SECURITY - DEFAULTS - WHATSAPP - AUDIT_CONFIG - DIGEST - ANALYTICS - INTEGRATIONS - COMMUNICATION - FEATURE_FLAGS -} - -enum NotificationChannel { - EMAIL - WHATSAPP - BOTH - NONE -} - -enum PartnerVisibility { - ADMIN_ONLY - JURY_VISIBLE - PUBLIC -} - -enum PartnerType { - SPONSOR - PARTNER - SUPPORTER - MEDIA - OTHER -} - -// ============================================================================= -// COMPETITION / ROUND ENGINE ENUMS -// ============================================================================= - -enum CompetitionStatus { - DRAFT - ACTIVE - CLOSED - ARCHIVED -} - -enum RoundType { - INTAKE - FILTERING - EVALUATION - SUBMISSION - MENTORING - LIVE_FINAL - DELIBERATION -} - -enum RoundStatus { - ROUND_DRAFT - ROUND_ACTIVE - ROUND_CLOSED - ROUND_ARCHIVED -} - -enum ProjectRoundStateValue { - PENDING - IN_PROGRESS - PASSED - REJECTED - COMPLETED - WITHDRAWN -} - -enum CapMode { - HARD - SOFT - NONE -} - -enum DeadlinePolicy { - HARD_DEADLINE - FLAG - GRACE -} - -enum JuryGroupMemberRole { - CHAIR - MEMBER - OBSERVER -} - -enum AssignmentIntentSource { - INVITE - ADMIN - SYSTEM -} - -enum AssignmentIntentStatus { - INTENT_PENDING - HONORED - OVERRIDDEN - EXPIRED - CANCELLED -} - -enum MentorMessageRole { - MENTOR_ROLE - APPLICANT_ROLE - ADMIN_ROLE -} - -enum SubmissionPromotionSource { - MENTOR_FILE - ADMIN_REPLACEMENT -} - -enum DeliberationMode { - SINGLE_WINNER_VOTE - FULL_RANKING -} - -enum DeliberationStatus { - DELIB_OPEN - VOTING - TALLYING - RUNOFF - DELIB_LOCKED -} - -enum TieBreakMethod { - TIE_RUNOFF - TIE_ADMIN_DECIDES - SCORE_FALLBACK -} - -enum DeliberationParticipantStatus { - REQUIRED - ABSENT_EXCUSED - REPLACED - REPLACEMENT_ACTIVE -} - -enum AwardEligibilityMode { - SEPARATE_POOL - STAY_IN_MAIN -} - -// ============================================================================= -// APPLICANT SYSTEM ENUMS -// ============================================================================= - -enum CompetitionCategory { - STARTUP // Existing companies - BUSINESS_CONCEPT // Students/graduates -} - -enum OceanIssue { - POLLUTION_REDUCTION - CLIMATE_MITIGATION - TECHNOLOGY_INNOVATION - SUSTAINABLE_SHIPPING - BLUE_CARBON - HABITAT_RESTORATION - COMMUNITY_CAPACITY - SUSTAINABLE_FISHING - CONSUMER_AWARENESS - OCEAN_ACIDIFICATION - OTHER -} - -enum TeamMemberRole { - LEAD // Primary contact / team lead - MEMBER // Regular team member - ADVISOR // Advisor/mentor from team side -} - -enum MentorAssignmentMethod { - MANUAL - AI_SUGGESTED - AI_AUTO - ALGORITHM -} - -// ============================================================================= -// USERS & AUTHENTICATION -// ============================================================================= - -model User { - id String @id @default(cuid()) - email String @unique - name String? - emailVerified DateTime? // Required by NextAuth Prisma adapter - role UserRole @default(APPLICANT) - roles UserRole[] @default([]) - status UserStatus @default(INVITED) - expertiseTags String[] @default([]) - maxAssignments Int? // Per-round limit - country String? // User's home country (for mentor matching) - nationality String? // User's nationality (for applicant profiles) - institution String? // User's institution/organization - metadataJson Json? @db.JsonB - - // Mentor onboarding email idempotency: stamped once when MENTOR role is first added. - mentorOnboardingSentAt DateTime? - - // Profile - bio String? // User bio for matching with project descriptions - profileImageKey String? // Storage key (e.g., "avatars/user123/1234567890.jpg") - profileImageProvider String? // Storage provider used: 's3' or 'local' - - // Phone and notification preferences (Phase 2) - phoneNumber String? - phoneNumberVerified Boolean @default(false) - notificationPreference NotificationChannel @default(EMAIL) - whatsappOptIn Boolean @default(false) - - // Onboarding (Phase 2B) - onboardingCompletedAt DateTime? - - // Password authentication (hybrid auth) - passwordHash String? // bcrypt hashed password - passwordSetAt DateTime? // When password was set - mustSetPassword Boolean @default(true) // Force setup on first login - - // Invitation token for one-click invite acceptance - inviteToken String? @unique - inviteTokenExpiresAt DateTime? - - // Password reset token - passwordResetToken String? @unique - passwordResetExpiresAt DateTime? - - // Digest & availability preferences - digestFrequency String @default("none") // 'none' | 'daily' | 'weekly' - preferredWorkload Int? - availabilityJson Json? @db.JsonB // { startDate?: string, endDate?: string } - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - lastLoginAt DateTime? - - // Relations - assignments Assignment[] - auditLogs AuditLog[] - gracePeriods GracePeriod[] - grantedGracePeriods GracePeriod[] @relation("GrantedBy") - notificationLogs NotificationLog[] - createdResources LearningResource[] @relation("ResourceCreatedBy") - resourceAccess ResourceAccess[] - submittedProjects Project[] @relation("ProjectSubmittedBy") - liveVotes LiveVote[] - - // Team membership & mentorship - teamMemberships TeamMember[] - mentorAssignments MentorAssignment[] @relation("MentorAssignments") - - // Awards - awardJurorships AwardJuror[] - awardVotes AwardVote[] - - // Filtering overrides - filteringOverrides FilteringResult[] @relation("FilteringOverriddenBy") - - // Award overrides - awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy") - awardEligibilityConfirms AwardEligibility[] @relation("AwardEligibilityConfirmer") - awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy") - - // In-app notifications - notifications InAppNotification[] @relation("UserNotifications") - notificationSettingsUpdated NotificationEmailSetting[] @relation("NotificationSettingUpdater") - - // Reminder logs - reminderLogs ReminderLog[] - - // Conflict of interest - conflictsOfInterest ConflictOfInterest[] - coiReviews ConflictOfInterest[] @relation("COIReviewedBy") - - // Evaluation summaries - generatedSummaries EvaluationSummary[] @relation("EvaluationSummaryGeneratedBy") - - // Mentor messages - mentorMessages MentorMessage[] @relation("MentorMessageSender") - - // Wizard templates - wizardTemplates WizardTemplate[] @relation("WizardTemplateCreatedBy") - - // Mentor notes - mentorNotes MentorNote[] @relation("MentorNoteAuthor") - - // Milestone completions - milestoneCompletions MentorMilestoneCompletion[] @relation("MilestoneCompletedBy") - - // Evaluation discussions - closedDiscussions EvaluationDiscussion[] @relation("DiscussionClosedBy") - discussionComments DiscussionComment[] @relation("DiscussionCommentAuthor") - - // Messaging - sentMessages Message[] @relation("MessageSender") - receivedMessages MessageRecipient[] @relation("MessageRecipient") - messageTemplates MessageTemplate[] @relation("MessageTemplateCreator") - - // Webhooks - webhooks Webhook[] @relation("WebhookCreator") - - // Digest logs - digestLogs DigestLog[] @relation("DigestLog") - - // NextAuth relations - accounts Account[] - 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") - - // AI Ranking - rankingSnapshots RankingSnapshot[] @relation("TriggeredRankingSnapshots") - - // Grand-finale logistics - finalistAttendances AttendingMember[] - - // Mentor change requests - mentorChangeRequestsRequested MentorChangeRequest[] @relation("MentorChangeRequester") - mentorChangeRequestsResolved MentorChangeRequest[] @relation("MentorChangeResolver") - - @@index([role]) - @@index([status]) -} - -// NextAuth.js required models -model Account { - id String @id @default(cuid()) - userId String - type String - provider String - providerAccountId String - refresh_token String? @db.Text - access_token String? @db.Text - expires_at Int? - token_type String? - scope String? - id_token String? @db.Text - session_state String? - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([provider, providerAccountId]) - @@index([userId]) -} - -model Session { - id String @id @default(cuid()) - sessionToken String @unique - userId String - expires DateTime - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@index([userId]) -} - -model VerificationToken { - identifier String - token String @unique - expires DateTime - - @@unique([identifier, token]) -} - -// ============================================================================= -// PROGRAMS & ROUNDS -// ============================================================================= - -model Program { - id String @id @default(cuid()) - name String // e.g., "Monaco Ocean Protection Challenge" - slug String? @unique // URL-friendly identifier for edition-wide applications - year Int // e.g., 2026 - status ProgramStatus @default(DRAFT) - description String? - settingsJson Json? @db.JsonB - - // Grand-finale logistics - defaultAttendeeCap Int @default(3) // Max team members allowed at the grand finale per finalist team - visaStatusVisibleToMembers Boolean @default(true) // Whether team members see their own visa status on the applicant dashboard - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - projects Project[] - learningResources LearningResource[] - partners Partner[] - specialAwards SpecialAward[] - taggingJobs TaggingJob[] - wizardTemplates WizardTemplate[] - mentorMilestones MentorMilestone[] - competitions Competition[] - - // Grand-finale logistics - finalistSlotQuotas FinalistSlotQuota[] - waitlistEntries WaitlistEntry[] - hotels Hotel[] - lunchEvent LunchEvent? - - @@unique([name, year]) - @@index([status]) -} - -model WizardTemplate { - id String @id @default(cuid()) - name String - description String? - config Json @db.JsonB - isGlobal Boolean @default(false) - programId String? - program Program? @relation(fields: [programId], references: [id], onDelete: Cascade) - createdBy String - creator User @relation("WizardTemplateCreatedBy", fields: [createdBy], references: [id]) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([programId]) - @@index([isGlobal]) -} - -// ============================================================================= -// EVALUATION FORMS -// ============================================================================= - -model EvaluationForm { - id String @id @default(cuid()) - roundId String - version Int @default(1) - category CompetitionCategory? // null=shared form, STARTUP or BUSINESS_CONCEPT=category-specific - - // Form configuration - // criteriaJson: Array of { id, label, description, scale, weight, required } - criteriaJson Json @db.JsonB - // scalesJson: { "1-5": { min, max, labels }, "1-10": { min, max, labels } } - scalesJson Json? @db.JsonB - isActive Boolean @default(false) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) - evaluations Evaluation[] - - @@unique([roundId, version, category]) - @@index([roundId, isActive]) - @@index([roundId, isActive, category]) -} - -// ============================================================================= -// PROJECTS -// ============================================================================= - -model Project { - id String @id @default(cuid()) - programId String - status ProjectStatus @default(SUBMITTED) - - // Core fields - title String - teamName String? - description String? @db.Text - - // Competition category - competitionCategory CompetitionCategory? - oceanIssue OceanIssue? - - // Location - country String? - geographicZone String? // "Europe, France" - - // Institution (for students/Business Concepts) - institution String? - - // Mentorship - wantsMentorship Boolean @default(false) - - // Founding date - foundedAt DateTime? // When the project/company was founded - - // Submission links (external, from CSV) - phase1SubmissionUrl String? - phase2SubmissionUrl String? - - // Referral tracking - referralSource String? - - // Internal admin fields - internalComments String? @db.Text - applicationStatus String? // "Received", etc. - - // Submission tracking - submissionSource SubmissionSource @default(MANUAL) - submittedByEmail String? - submittedAt DateTime? - submittedByUserId String? - - // Project branding - logoKey String? // Storage key (e.g., "logos/project456/1234567890.png") - logoProvider String? // Storage provider used: 's3' or 'local' - - // Draft support - isDraft Boolean @default(false) - draftDataJson Json? @db.JsonB // Form data for drafts - draftExpiresAt DateTime? - - // Flexible fields - tags String[] @default([]) // "Ocean Conservation", "Tech", etc. - metadataJson Json? @db.JsonB // Custom fields from Typeform, etc. - externalIdsJson Json? @db.JsonB // Typeform ID, Notion ID, etc. - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - program Program @relation(fields: [programId], references: [id], onDelete: Cascade) - files ProjectFile[] - assignments Assignment[] - submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull) - teamMembers TeamMember[] - mentorAssignments MentorAssignment[] - mentorFiles MentorFile[] - mentorChangeRequests MentorChangeRequest[] - filteringResults FilteringResult[] - awardEligibilities AwardEligibility[] - awardVotes AwardVote[] - wonAwards SpecialAward[] @relation("AwardWinner") - projectTags ProjectTag[] - statusHistory ProjectStatusHistory[] - mentorMessages MentorMessage[] - evaluationSummaries EvaluationSummary[] - evaluationDiscussions EvaluationDiscussion[] - cohortProjects CohortProject[] - - // ── Competition/Round architecture relations ── - projectRoundStates ProjectRoundState[] - assignmentIntents AssignmentIntent[] - deliberationVotes DeliberationVote[] - deliberationResults DeliberationResult[] - submissionPromotions SubmissionPromotionEvent[] - notificationLogs NotificationLog[] - - // Grand-finale logistics - waitlistEntry WaitlistEntry? - finalistConfirmation FinalistConfirmation? - externalLunchAttendees ExternalAttendee[] - - @@index([programId]) - @@index([status]) - @@index([tags]) - @@index([submissionSource]) - @@index([submittedByUserId]) - @@index([competitionCategory]) - @@index([oceanIssue]) - @@index([country]) -} - -model FileRequirement { - id String @id @default(cuid()) - roundId String - name String - description String? - acceptedMimeTypes String[] // e.g. ["application/pdf", "video/*"] - maxSizeMB Int? // Max file size in MB - isRequired Boolean @default(true) - sortOrder Int @default(0) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) - files ProjectFile[] - - @@index([roundId]) -} - -model ProjectFile { - id String @id @default(cuid()) - projectId String - roundId String? // Which round this file was submitted for - requirementId String? // FK to FileRequirement (if uploaded against a requirement) - - // File info - fileType FileType - fileName String - mimeType String - size Int // bytes - pageCount Int? // Number of pages (PDFs, presentations, etc.) - - // 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 - - // MinIO location - bucket String - objectKey String - - isLate Boolean @default(false) // Uploaded after round deadline - - // Versioning - version Int @default(1) - replacedById String? // FK to the newer file that replaced this one - - // ── Competition/Round architecture fields ── - submissionWindowId String? // FK to SubmissionWindow - submissionFileRequirementId String? // FK to SubmissionFileRequirement - - createdAt DateTime @default(now()) - - // Relations - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - requirement FileRequirement? @relation(fields: [requirementId], references: [id], onDelete: SetNull) - replacedBy ProjectFile? @relation("FileVersions", fields: [replacedById], references: [id], onDelete: SetNull) - replacements ProjectFile[] @relation("FileVersions") - submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull) - submissionFileRequirement SubmissionFileRequirement? @relation(fields: [submissionFileRequirementId], references: [id], onDelete: SetNull) - promotedFrom MentorFile? @relation("PromotedFromMentorFile") - - @@unique([bucket, objectKey]) - @@index([projectId]) - @@index([fileType]) - @@index([requirementId]) - @@index([submissionWindowId]) - @@index([submissionFileRequirementId]) -} - -// ============================================================================= -// ASSIGNMENTS & EVALUATIONS -// ============================================================================= - -model Assignment { - id String @id @default(cuid()) - userId String - projectId String - roundId String - - // Assignment info - method AssignmentMethod @default(MANUAL) - isRequired Boolean @default(true) - isCompleted Boolean @default(false) - - // AI assignment metadata - aiConfidenceScore Float? // 0-1 confidence from AI - expertiseMatchScore Float? // 0-1 match score - aiReasoning String? @db.Text - - createdAt DateTime @default(now()) - createdBy String? // Admin who created the assignment - - // Competition/Round architecture — jury group link - 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) - evaluation Evaluation? - conflictOfInterest ConflictOfInterest? - - @@unique([userId, projectId, roundId]) - @@index([roundId]) - @@index([userId]) - @@index([projectId]) - @@index([isCompleted]) - @@index([projectId, userId]) - @@index([juryGroupId]) - @@index([roundId, isCompleted]) -} - -model Evaluation { - id String @id @default(cuid()) - assignmentId String @unique - formId String - - // Status - status EvaluationStatus @default(NOT_STARTED) - - // Scores - // criterionScoresJson: { "criterion_id": score, ... } - criterionScoresJson Json? @db.JsonB - globalScore Int? // 1-10 - binaryDecision Boolean? // Yes/No for semi-finalist - feedbackText String? @db.Text - - // Timestamps - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - submittedAt DateTime? - - // Relations - assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade) - form EvaluationForm @relation(fields: [formId], references: [id], onDelete: Cascade) - - @@index([status]) - @@index([submittedAt]) - @@index([formId]) - @@index([status, formId]) -} - -// ============================================================================= -// GRACE PERIODS -// ============================================================================= - -model GracePeriod { - id String @id @default(cuid()) - roundId String - userId String - projectId String? // Optional: specific project or all projects in round - - extendedUntil DateTime - reason String? @db.Text - grantedById String - - createdAt DateTime @default(now()) - - // Relations - round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - grantedBy User @relation("GrantedBy", fields: [grantedById], references: [id]) - - @@index([roundId]) - @@index([userId]) - @@index([extendedUntil]) - @@index([grantedById]) - @@index([projectId]) - @@index([roundId, userId, extendedUntil]) -} - -// ============================================================================= -// SYSTEM SETTINGS -// ============================================================================= - -model SystemSettings { - id String @id @default(cuid()) - key String @unique - value String @db.Text - type SettingType @default(STRING) - category SettingCategory - - description String? - isSecret Boolean @default(false) // If true, value is encrypted - - updatedAt DateTime @updatedAt - updatedBy String? - - @@index([category]) -} - -// ============================================================================= -// AUDIT LOGGING -// ============================================================================= - -model AuditLog { - id String @id @default(cuid()) - userId String? - - // Event info - action String // "CREATE", "UPDATE", "DELETE", "LOGIN", "EXPORT", etc. - entityType String // "Round", "Project", "Evaluation", etc. - entityId String? - - // Details - detailsJson Json? @db.JsonB // Before/after values, additional context - previousDataJson Json? @db.JsonB // Previous state for tracking changes - - // Request info - ipAddress String? - userAgent String? - sessionId String? - - timestamp DateTime @default(now()) - - // Relations - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - - @@index([userId]) - @@index([action]) - @@index([entityType, entityId]) - @@index([timestamp]) - @@index([entityType, entityId, timestamp]) - @@index([sessionId]) -} - -// ============================================================================= -// AI USAGE TRACKING -// ============================================================================= - -model AIUsageLog { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - - // Who/what triggered it - userId String? - action String // ASSIGNMENT, FILTERING, AWARD_ELIGIBILITY, MENTOR_MATCHING - entityType String? // Round, Project, Award - entityId String? - - // What was used - model String // gpt-4o, gpt-4o-mini, o1, claude-sonnet-4-5, etc. - provider String? // openai, anthropic, litellm - promptTokens Int - completionTokens Int - totalTokens Int - - // Cost tracking - estimatedCostUsd Decimal? @db.Decimal(10, 6) - - // Request context - batchSize Int? - itemsProcessed Int? - - // Status - status String // SUCCESS, PARTIAL, ERROR - errorMessage String? - - // Detailed data (optional) - detailsJson Json? @db.JsonB - - @@index([userId]) - @@index([action]) - @@index([createdAt]) - @@index([model]) -} - -// ============================================================================= -// NOTIFICATION LOG (Phase 2) -// ============================================================================= - -model NotificationLog { - id String @id @default(cuid()) - userId String? - channel NotificationChannel @default(EMAIL) - provider String? // META, TWILIO, SMTP - type String // MAGIC_LINK, REMINDER, ANNOUNCEMENT, JURY_INVITATION, ADVANCEMENT_NOTIFICATION, etc. - status String // PENDING, SENT, DELIVERED, FAILED - externalId String? // Message ID from provider - errorMsg String? @db.Text - - // Bulk notification tracking - email String? // Recipient email address - roundId String? - projectId String? - batchId String? // Groups emails from same send operation - - createdAt DateTime @default(now()) - - // Relations - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - round Round? @relation(fields: [roundId], references: [id], onDelete: SetNull) - project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) - - @@index([userId]) - @@index([status]) - @@index([createdAt]) - @@index([roundId, type]) - @@index([projectId]) - @@index([batchId]) - @@index([email]) - @@index([type, status]) -} - -// ============================================================================= -// IN-APP NOTIFICATIONS -// ============================================================================= - -model InAppNotification { - id String @id @default(cuid()) - userId String - type String // FILTERING_COMPLETE, NEW_APPLICATION, ASSIGNED_TO_PROJECT, etc. - priority String @default("normal") // low, normal, high, urgent - icon String? // lucide icon name - title String - message String @db.Text - linkUrl String? // Where to navigate when clicked - linkLabel String? // CTA text - metadata Json? @db.JsonB // Extra context (projectId, roundId, etc.) - groupKey String? // For batching similar notifications - - isRead Boolean @default(false) - readAt DateTime? - expiresAt DateTime? // Auto-dismiss after date - - createdAt DateTime @default(now()) - - // Relations - user User @relation("UserNotifications", fields: [userId], references: [id], onDelete: Cascade) - - @@index([userId, isRead]) - @@index([userId, createdAt]) - @@index([groupKey]) -} - -model NotificationEmailSetting { - id String @id @default(cuid()) - notificationType String @unique // e.g., "ADVANCED_TO_ROUND", "ASSIGNED_TO_PROJECT" - category String // "team", "jury", "mentor", "admin" - label String // Human-readable label for admin UI - description String? // Help text - sendEmail Boolean @default(true) - emailSubject String? // Custom subject template (optional) - emailTemplate String? @db.Text // Custom body template (optional) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - updatedById String? - updatedBy User? @relation("NotificationSettingUpdater", fields: [updatedById], references: [id]) - - @@index([category]) -} - -// ============================================================================= -// LEARNING HUB (Phase 2) -// ============================================================================= - -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 - - // File storage (for uploaded resources) - fileName String? - mimeType String? - size Int? - bucket String? - objectKey String? - - // Cover image (stored in MinIO) - coverImageKey String? - - // External link - externalUrl String? - - sortOrder Int @default(0) - isPublished Boolean @default(false) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - createdById String - - // Relations - program Program? @relation(fields: [programId], references: [id], onDelete: SetNull) - createdBy User @relation("ResourceCreatedBy", fields: [createdById], references: [id]) - accessLogs ResourceAccess[] - - @@index([programId]) - @@index([isPublished]) - @@index([sortOrder]) -} - -model ResourceAccess { - id String @id @default(cuid()) - resourceId String - userId String - accessedAt DateTime @default(now()) - ipAddress String? - - // Relations - resource LearningResource @relation(fields: [resourceId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@index([resourceId]) - @@index([userId]) - @@index([accessedAt]) -} - -// ============================================================================= -// PARTNER MANAGEMENT (Phase 2) -// ============================================================================= - -model Partner { - id String @id @default(cuid()) - programId String? // null = global partner - name String - description String? @db.Text - website String? - partnerType PartnerType @default(PARTNER) - visibility PartnerVisibility @default(ADMIN_ONLY) - - // Logo file - logoFileName String? - logoBucket String? - logoObjectKey String? - - sortOrder Int @default(0) - isActive Boolean @default(true) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - program Program? @relation(fields: [programId], references: [id], onDelete: SetNull) - - @@index([programId]) - @@index([partnerType]) - @@index([visibility]) - @@index([isActive]) - @@index([sortOrder]) -} - -// ============================================================================= -// EXPERTISE TAGS (Phase 2B) -// ============================================================================= - -model ExpertiseTag { - id String @id @default(cuid()) - name String @unique - description String? - category String? // "Marine Science", "Technology", "Policy" - color String? // Hex for badge - isActive Boolean @default(true) - sortOrder Int @default(0) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - projectTags ProjectTag[] - - @@index([category]) - @@index([isActive]) - @@index([sortOrder]) -} - -// Project-Tag relationship for AI tagging -model ProjectTag { - id String @id @default(cuid()) - projectId String - tagId String - confidence Float @default(1.0) // AI confidence score 0-1 - source String @default("AI") // "AI" or "MANUAL" - createdAt DateTime @default(now()) - - // Relations - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - tag ExpertiseTag @relation(fields: [tagId], references: [id], onDelete: Cascade) - - @@unique([projectId, tagId]) - @@index([projectId]) - @@index([tagId]) -} - -// ============================================================================= -// LIVE VOTING (Phase 2B) -// ============================================================================= - -model LiveVotingSession { - id String @id @default(cuid()) - roundId String? @unique - status String @default("NOT_STARTED") // NOT_STARTED, IN_PROGRESS, PAUSED, COMPLETED - currentProjectIndex Int @default(0) - currentProjectId String? - votingStartedAt DateTime? - votingEndsAt DateTime? - projectOrderJson Json? @db.JsonB // Array of project IDs in presentation order - - // Criteria-based voting - votingMode String @default("simple") // "simple" (1-10) | "criteria" (per-criterion scores) - criteriaJson Json? @db.JsonB // Array of { id, label, description, scale, weight } - - // Audience & presentation settings - allowAudienceVotes Boolean @default(false) - audienceVoteWeight Float @default(0) // 0.0 to 1.0 - tieBreakerMethod String @default("admin_decides") // 'admin_decides' | 'highest_individual' | 'revote' - presentationSettingsJson Json? @db.JsonB - - // Audience voting configuration - audienceVotingMode String @default("disabled") // "disabled" | "per_project" | "per_category" | "favorites" - audienceMaxFavorites Int @default(3) // For "favorites" mode - audienceRequireId Boolean @default(false) // Require email/phone for audience - audienceVotingDuration Int? // Minutes (null = same as jury) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - round Round? @relation(fields: [roundId], references: [id], onDelete: Cascade) - votes LiveVote[] - audienceVoters AudienceVoter[] - - @@index([status]) -} - -model LiveVote { - id String @id @default(cuid()) - sessionId String - projectId String - userId String? // Nullable for audience voters without accounts - score Int // 1-10 (or weighted score for criteria mode) - isAudienceVote Boolean @default(false) - votedAt DateTime @default(now()) - - // Criteria scores (used when votingMode="criteria") - criterionScoresJson Json? @db.JsonB // { [criterionId]: score } - null for simple mode - - // Audience voter link - audienceVoterId String? - - // Relations - session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) - user User? @relation(fields: [userId], references: [id], onDelete: Cascade) - audienceVoter AudienceVoter? @relation(fields: [audienceVoterId], references: [id], onDelete: Cascade) - - @@unique([sessionId, projectId, userId]) - @@unique([sessionId, projectId, audienceVoterId]) - @@index([sessionId]) - @@index([projectId]) - @@index([userId]) - @@index([audienceVoterId]) -} - -model AudienceVoter { - id String @id @default(cuid()) - sessionId String - token String @unique // Unique voting token (UUID) - identifier String? // Optional: email, phone, or name - identifierType String? // "email" | "phone" | "name" | "anonymous" - ipAddress String? - userAgent String? - createdAt DateTime @default(now()) - - // Relations - session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) - votes LiveVote[] - - @@index([sessionId]) - @@index([token]) -} - -// ============================================================================= -// TEAM MEMBERSHIP -// ============================================================================= - -model TeamMember { - id String @id @default(cuid()) - projectId String - userId String - role TeamMemberRole @default(MEMBER) - title String? // "CEO", "CTO", etc. - joinedAt DateTime @default(now()) - - // Relations - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([projectId, userId]) - @@index([projectId]) - @@index([userId]) - @@index([role]) -} - -// ============================================================================= -// MENTOR ASSIGNMENT -// ============================================================================= - -model MentorAssignment { - id String @id @default(cuid()) - projectId String // Team can have multiple mentors; uniqueness enforced via composite below - mentorId String // User with MENTOR role or expertise - - // Assignment tracking - method MentorAssignmentMethod @default(MANUAL) - assignedAt DateTime @default(now()) - assignedBy String? // Admin who assigned - - // Per-assignment email idempotency: stamped once the MENTOR-side notification - // email has been sent (the "you've been assigned a project" email to the mentor). - notificationSentAt DateTime? - - // Stamped once the TEAM has been introduced to this mentor (the "meet your - // mentor" email with mentor contact info). Fired by `activateRound` for - // MENTORING rounds and by mentor.assign when the project's MENTORING round - // is already ROUND_ACTIVE. Independent from notificationSentAt above. - teamIntroducedAt DateTime? - - // AI assignment metadata - aiConfidenceScore Float? - expertiseMatchScore Float? - aiReasoning String? @db.Text - - // Tracking - completionStatus String @default("in_progress") // 'in_progress' | 'completed' | 'paused' - lastViewedAt DateTime? - - // Drop tracking — null while assignment is active - droppedAt DateTime? - droppedReason String? @db.Text - droppedBy String? // 'mentor' | 'admin' | 'finalist_unconfirmed' - - // ── Competition/Round architecture — workspace activation ── - workspaceEnabled Boolean @default(false) - workspaceOpenAt DateTime? - workspaceCloseAt DateTime? - - // Relations - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - mentor User @relation("MentorAssignments", fields: [mentorId], references: [id]) - notes MentorNote[] - 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 -// ============================================================================= - -enum FilteringOutcome { - PASSED - FILTERED_OUT - FLAGGED -} - -enum FilteringRuleType { - FIELD_BASED - DOCUMENT_CHECK - AI_SCREENING -} - -model FilteringRule { - id String @id @default(cuid()) - roundId String - name String - ruleType FilteringRuleType - configJson Json @db.JsonB // Conditions, logic, action per rule type - priority Int @default(0) - isActive Boolean @default(true) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) - - @@index([roundId]) - @@index([priority]) -} - -model FilteringResult { - id String @id @default(cuid()) - roundId String - projectId String - outcome FilteringOutcome - ruleResultsJson Json? @db.JsonB // Per-rule results - aiScreeningJson Json? @db.JsonB // AI screening details - - // Admin override - overriddenBy String? - overriddenAt DateTime? - overrideReason String? @db.Text - finalOutcome FilteringOutcome? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - overriddenByUser User? @relation("FilteringOverriddenBy", fields: [overriddenBy], references: [id], onDelete: SetNull) - - @@unique([roundId, projectId]) - @@index([roundId]) - @@index([projectId]) - @@index([outcome]) -} - -// Tracks progress of long-running filtering jobs -model FilteringJob { - id String @id @default(cuid()) - roundId String - status FilteringJobStatus @default(PENDING) - totalProjects Int @default(0) - totalBatches Int @default(0) - currentBatch Int @default(0) - processedCount Int @default(0) - passedCount Int @default(0) - filteredCount Int @default(0) - flaggedCount Int @default(0) - errorMessage String? @db.Text - startedAt DateTime? - completedAt DateTime? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) - - @@index([roundId]) - @@index([status]) -} - -enum FilteringJobStatus { - PENDING - RUNNING - COMPLETED - FAILED -} - -// Tracks progress of long-running AI assignment jobs -model AssignmentJob { - id String @id @default(cuid()) - roundId String - status AssignmentJobStatus @default(PENDING) - totalProjects Int @default(0) - totalBatches Int @default(0) - currentBatch Int @default(0) - processedCount Int @default(0) - suggestionsCount Int @default(0) - suggestionsJson Json? @db.JsonB // Stores the AI-generated suggestions - errorMessage String? @db.Text - startedAt DateTime? - completedAt DateTime? - fallbackUsed Boolean @default(false) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) - - @@index([roundId]) - @@index([status]) -} - -enum AssignmentJobStatus { - PENDING - RUNNING - COMPLETED - FAILED -} - -// ============================================================================= -// AI RANKING MODELS -// ============================================================================= - -enum RankingTriggerType { - MANUAL // Admin clicked "Run ranking" - AUTO // Auto-triggered by assignment completion - RETROACTIVE // Retroactive scan on deployment - QUICK // Quick-rank mode (no preview) -} - -enum RankingMode { - 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 -} - -enum RankingSnapshotStatus { - PENDING - RUNNING - COMPLETED - FAILED -} - -// Captures a point-in-time AI ranking run for a round -model RankingSnapshot { - id String @id @default(cuid()) - - roundId String - - // Trigger metadata - triggeredById String? // null = auto-triggered - triggerType RankingTriggerType @default(MANUAL) - - // Criteria used - criteriaText String @db.Text - parsedRulesJson Json @db.JsonB - - // Results per category (either can be null/empty if no projects in that category) - startupRankingJson Json? @db.JsonB - conceptRankingJson Json? @db.JsonB - - // Evaluation data freeze (raw scores at time of ranking) - evaluationDataJson Json? @db.JsonB - - // Mode and status - mode RankingMode @default(PREVIEW) - status RankingSnapshotStatus @default(COMPLETED) - - // Post-drag-and-drop reorders (Phase 2 will populate this) - reordersJson Json? @db.JsonB - - // AI metadata - model String? - tokensUsed Int @default(0) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - round Round @relation("RoundRankingSnapshots", fields: [roundId], references: [id], onDelete: Cascade) - triggeredBy User? @relation("TriggeredRankingSnapshots", fields: [triggeredById], references: [id], onDelete: SetNull) - - @@index([roundId]) - @@index([triggeredById]) - @@index([createdAt]) - @@index([roundId, createdAt]) -} - -// Tracks progress of long-running AI tagging jobs -model TaggingJob { - id String @id @default(cuid()) - programId String? // If tagging entire program - roundId String? // If tagging single round - status TaggingJobStatus @default(PENDING) - totalProjects Int @default(0) - processedCount Int @default(0) - taggedCount Int @default(0) - skippedCount Int @default(0) - failedCount Int @default(0) - errorMessage String? @db.Text - errorsJson Json? @db.JsonB // Array of error messages - startedAt DateTime? - completedAt DateTime? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations (optional - can tag by program) - program Program? @relation(fields: [programId], references: [id], onDelete: Cascade) - - @@index([programId]) - @@index([status]) -} - -enum TaggingJobStatus { - PENDING - RUNNING - COMPLETED - FAILED -} - -// ============================================================================= -// SPECIAL AWARDS SYSTEM -// ============================================================================= - -enum AwardScoringMode { - PICK_WINNER - RANKED - SCORED -} - -enum AwardStatus { - DRAFT - NOMINATIONS_OPEN - VOTING_OPEN - CLOSED - ARCHIVED -} - -enum EligibilityMethod { - AUTO - MANUAL -} - -model SpecialAward { - id String @id @default(cuid()) - programId String - name String - description String? @db.Text - status AwardStatus @default(DRAFT) - - // Criteria - criteriaText String? @db.Text // Plain-language criteria for AI - autoTagRulesJson Json? @db.JsonB // Deterministic eligibility rules - useAiEligibility Boolean @default(true) // Whether AI evaluates eligibility - - // Scoring - scoringMode AwardScoringMode @default(PICK_WINNER) - maxRankedPicks Int? // For RANKED mode - - // Voting window - votingStartAt DateTime? - votingEndAt DateTime? - - // Evaluation form (for SCORED mode) - evaluationFormId String? - - // Winner - winnerProjectId String? - winnerOverridden Boolean @default(false) - winnerOverriddenBy String? // FK to User who overrode the winner - - sortOrder Int @default(0) - - // ── Competition/Round architecture fields ── - competitionId String? - evaluationRoundId String? - juryGroupId String? - eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN) - decisionMode String? // "JURY_VOTE" | "ADMIN_DECISION" - shortlistSize Int @default(10) - - // Eligibility job tracking - eligibilityJobStatus String? // PENDING, PROCESSING, COMPLETED, FAILED - eligibilityJobTotal Int? // total projects to process - eligibilityJobDone Int? // completed so far - eligibilityJobError String? @db.Text // error message if failed - eligibilityJobStarted DateTime? // when job started - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - program Program @relation(fields: [programId], references: [id], onDelete: Cascade) - winnerProject Project? @relation("AwardWinner", fields: [winnerProjectId], references: [id], onDelete: SetNull) - overriddenByUser User? @relation("AwardOverriddenBy", fields: [winnerOverriddenBy], references: [id], onDelete: SetNull) - eligibilities AwardEligibility[] - jurors AwardJuror[] - 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") - - @@index([programId]) - @@index([status]) - @@index([sortOrder]) - @@index([competitionId]) - @@index([evaluationRoundId]) -} - -model AwardEligibility { - id String @id @default(cuid()) - awardId String - projectId String - method EligibilityMethod @default(AUTO) - eligible Boolean @default(false) - aiReasoningJson Json? @db.JsonB - qualityScore Float? - shortlisted Boolean @default(false) - - // Admin override - overriddenBy String? - overriddenAt DateTime? - - // Shortlist confirmation - confirmedAt DateTime? - confirmedBy String? - - // Pool notification tracking - notifiedAt DateTime? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade) - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - overriddenByUser User? @relation("AwardEligibilityOverriddenBy", fields: [overriddenBy], references: [id], onDelete: SetNull) - confirmer User? @relation("AwardEligibilityConfirmer", fields: [confirmedBy], references: [id], onDelete: SetNull) - - @@unique([awardId, projectId]) - @@index([awardId]) - @@index([projectId]) - @@index([eligible]) - @@index([awardId, eligible]) -} - -model AwardJuror { - id String @id @default(cuid()) - awardId String - userId String - isChair Boolean @default(false) - - createdAt DateTime @default(now()) - - // Relations - award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([awardId, userId]) - @@index([awardId]) - @@index([userId]) -} - -model AwardVote { - 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 - award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - - @@unique([awardId, userId, projectId]) - @@index([awardId]) - @@index([userId]) - @@index([projectId]) - @@index([awardId, userId]) -} - -// ============================================================================= -// REMINDER LOG (Evaluation Deadline Reminders) -// ============================================================================= - -model ReminderLog { - id String @id @default(cuid()) - roundId String - userId String - type String // "3_DAYS", "24H", "1H" - sentAt DateTime @default(now()) - - // Relations - round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([roundId, userId, type]) - @@index([roundId]) -} - -// ============================================================================= -// CONFLICT OF INTEREST -// ============================================================================= - -model ConflictOfInterest { - id String @id @default(cuid()) - assignmentId String @unique - userId String - projectId String - hasConflict Boolean @default(false) - conflictType String? // "financial", "personal", "organizational", "other" - description String? @db.Text - declaredAt DateTime @default(now()) - - // Admin review - reviewedById String? - reviewedAt DateTime? - reviewAction String? // "cleared", "reassigned", "noted" - - // Relations - assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id]) - reviewedBy User? @relation("COIReviewedBy", fields: [reviewedById], references: [id]) - - @@index([userId]) - @@index([hasConflict]) - @@index([projectId]) - @@index([userId, hasConflict]) -} - -// ============================================================================= -// AI EVALUATION SUMMARY -// ============================================================================= - -model EvaluationSummary { - id String @id @default(cuid()) - projectId String - roundId String - summaryJson Json @db.JsonB - generatedAt DateTime @default(now()) - generatedById String - model String - tokensUsed Int - - // Relations - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) - generatedBy User @relation("EvaluationSummaryGeneratedBy", fields: [generatedById], references: [id]) - - @@unique([projectId, roundId]) - @@index([roundId]) -} - -// ============================================================================= -// PROJECT STATUS HISTORY -// ============================================================================= - -model ProjectStatusHistory { - id String @id @default(cuid()) - projectId String - status ProjectStatus - changedAt DateTime @default(now()) - changedBy String? - - // Relations - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - - @@index([projectId, changedAt]) -} - -// ============================================================================= -// MENTOR MESSAGES -// ============================================================================= - -model MentorMessage { - id String @id @default(cuid()) - projectId String - senderId String - message String @db.Text - isRead Boolean @default(false) - createdAt DateTime @default(now()) - - // ── Competition/Round architecture fields ── - workspaceId String? // FK to MentorAssignment (used as workspace) - senderRole MentorMessageRole? - - // Relations - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - sender User @relation("MentorMessageSender", fields: [senderId], references: [id]) - workspace MentorAssignment? @relation(fields: [workspaceId], references: [id], onDelete: Cascade) - - @@index([projectId, createdAt]) - @@index([workspaceId]) -} - -// RoundTemplate model RETIRED in Phase 6 — replaced by WizardTemplate for pipelines. - -// ============================================================================= -// MENTOR NOTES & MILESTONES -// ============================================================================= - -model MentorNote { - id String @id @default(cuid()) - mentorAssignmentId String - authorId String - content String @db.Text - isVisibleToAdmin Boolean @default(true) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade) - author User @relation("MentorNoteAuthor", fields: [authorId], references: [id]) - - @@index([mentorAssignmentId]) - @@index([authorId]) -} - -model MentorMilestone { - id String @id @default(cuid()) - programId String - name String - description String? @db.Text - isRequired Boolean @default(false) - deadlineOffsetDays Int? - sortOrder Int @default(0) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - program Program @relation(fields: [programId], references: [id], onDelete: Cascade) - completions MentorMilestoneCompletion[] - - @@index([programId]) - @@index([sortOrder]) -} - -model MentorMilestoneCompletion { - id String @id @default(cuid()) - milestoneId String - mentorAssignmentId String - completedById String - completedAt DateTime @default(now()) - - // Relations - milestone MentorMilestone @relation(fields: [milestoneId], references: [id], onDelete: Cascade) - mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade) - completedBy User @relation("MilestoneCompletedBy", fields: [completedById], references: [id]) - - @@unique([milestoneId, mentorAssignmentId]) - @@index([mentorAssignmentId]) - @@index([completedById]) -} - -// ============================================================================= -// EVALUATION DISCUSSIONS -// ============================================================================= - -model EvaluationDiscussion { - id String @id @default(cuid()) - projectId String - roundId String - status String @default("open") // 'open' | 'closed' - createdAt DateTime @default(now()) - closedAt DateTime? - closedById String? - - // Relations - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) - closedBy User? @relation("DiscussionClosedBy", fields: [closedById], references: [id], onDelete: SetNull) - comments DiscussionComment[] - - @@unique([projectId, roundId]) - @@index([roundId]) - @@index([closedById]) -} - -model DiscussionComment { - id String @id @default(cuid()) - discussionId String - userId String - content String @db.Text - - createdAt DateTime @default(now()) - - // Relations - discussion EvaluationDiscussion @relation(fields: [discussionId], references: [id], onDelete: Cascade) - user User @relation("DiscussionCommentAuthor", fields: [userId], references: [id]) - - @@index([discussionId]) - @@index([userId]) -} - -// ============================================================================= -// MESSAGING SYSTEM -// ============================================================================= - -model Message { - id String @id @default(cuid()) - senderId String - recipientType String // 'USER', 'ROLE', 'ROUND_JURY', 'PROGRAM_TEAM', 'ALL' - recipientFilter Json? @db.JsonB - roundId String? - templateId String? - subject String - body String @db.Text - deliveryChannels String[] - - scheduledAt DateTime? - sentAt DateTime? - metadata Json? @db.JsonB - - createdAt DateTime @default(now()) - - // Relations - sender User @relation("MessageSender", fields: [senderId], references: [id]) - round Round? @relation(fields: [roundId], references: [id], onDelete: SetNull) - template MessageTemplate? @relation(fields: [templateId], references: [id], onDelete: SetNull) - recipients MessageRecipient[] - - @@index([senderId]) - @@index([roundId]) - @@index([sentAt]) -} - -model MessageRecipient { - id String @id @default(cuid()) - messageId String - userId String - channel String // 'EMAIL', 'IN_APP', etc. - isRead Boolean @default(false) - readAt DateTime? - deliveredAt DateTime? - - // Relations - message Message @relation(fields: [messageId], references: [id], onDelete: Cascade) - user User @relation("MessageRecipient", fields: [userId], references: [id], onDelete: Cascade) - - @@unique([messageId, userId, channel]) - @@index([userId]) -} - -model MessageTemplate { - id String @id @default(cuid()) - name String - category String // 'SYSTEM', 'EVALUATION', 'ASSIGNMENT' - subject String - body String @db.Text - variables Json? @db.JsonB - isActive Boolean @default(true) - createdBy String - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - creator User @relation("MessageTemplateCreator", fields: [createdBy], references: [id]) - messages Message[] - - @@index([category]) - @@index([isActive]) -} - -// ============================================================================= -// WEBHOOKS -// ============================================================================= - -model Webhook { - id String @id @default(cuid()) - name String - url String - secret String - events String[] - headers Json? @db.JsonB - maxRetries Int @default(3) - isActive Boolean @default(true) - createdById String - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - createdBy User @relation("WebhookCreator", fields: [createdById], references: [id]) - deliveries WebhookDelivery[] - - @@index([isActive]) - @@index([createdById]) -} - -model WebhookDelivery { - id String @id @default(cuid()) - webhookId String - event String - payload Json @db.JsonB - status String @default("PENDING") // 'PENDING', 'DELIVERED', 'FAILED' - responseStatus Int? - responseBody String? @db.Text - attempts Int @default(0) - lastAttemptAt DateTime? - - createdAt DateTime @default(now()) - - // Relations - webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade) - - @@index([webhookId]) - @@index([status]) - @@index([event]) -} - -// ============================================================================= -// DIGEST LOGS -// ============================================================================= - -model DigestLog { - id String @id @default(cuid()) - userId String - digestType String // 'daily' | 'weekly' - contentJson Json @db.JsonB - sentAt DateTime @default(now()) - - // Relations - user User @relation("DigestLog", fields: [userId], references: [id], onDelete: Cascade) - - @@index([userId]) - @@index([sentAt]) -} - -// ============================================================================= -// COHORT & LIVE PROGRESS (formerly part of Stage engine, now refit to Round) -// ============================================================================= - -model Cohort { - id String @id @default(cuid()) - roundId String - name String - votingMode String @default("simple") // simple, criteria, ranked - isOpen Boolean @default(false) - windowOpenAt DateTime? - windowCloseAt DateTime? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) - projects CohortProject[] - - @@index([roundId]) - @@index([isOpen]) -} - -model CohortProject { - id String @id @default(cuid()) - cohortId String - projectId String - sortOrder Int @default(0) - - createdAt DateTime @default(now()) - - // Relations - cohort Cohort @relation(fields: [cohortId], references: [id], onDelete: Cascade) - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - - @@unique([cohortId, projectId]) - @@index([cohortId]) - @@index([projectId]) - @@index([sortOrder]) -} - -model LiveProgressCursor { - id String @id @default(cuid()) - roundId String @unique - sessionId String @unique @default(cuid()) - activeProjectId String? - activeOrderIndex Int @default(0) - isPaused Boolean @default(false) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) - - @@index([sessionId]) -} - -model DecisionAuditLog { - id String @id @default(cuid()) - eventType String // stage.transitioned, routing.executed, filtering.completed, etc. - entityType String - entityId String - actorId String? - detailsJson Json? @db.JsonB - snapshotJson Json? @db.JsonB // State at time of decision - - createdAt DateTime @default(now()) - - @@index([eventType]) - @@index([entityType, entityId]) - @@index([actorId]) - @@index([createdAt]) -} - -// ============================================================================= -// COMPETITION / ROUND ENGINE MODELS (NEW — coexists with Pipeline/Track/Stage) -// ============================================================================= - -model Competition { - id String @id @default(cuid()) - programId String - name String - slug String @unique - status CompetitionStatus @default(DRAFT) - - // Competition-wide settings - categoryMode String @default("SHARED") - startupFinalistCount Int @default(3) - conceptFinalistCount Int @default(3) - - // Notification preferences - notifyOnRoundAdvance Boolean @default(true) - notifyOnDeadlineApproach Boolean @default(true) - deadlineReminderDays Int[] @default([7, 3, 1]) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - program Program @relation(fields: [programId], references: [id], onDelete: Cascade) - rounds Round[] - juryGroups JuryGroup[] - submissionWindows SubmissionWindow[] - specialAwards SpecialAward[] - deliberationSessions DeliberationSession[] - resultLocks ResultLock[] - - @@index([programId]) - @@index([status]) -} - -model Round { - id String @id @default(cuid()) - competitionId String - name String - slug String - roundType RoundType - status RoundStatus @default(ROUND_DRAFT) - sortOrder Int @default(0) - - // Time windows - windowOpenAt DateTime? - windowCloseAt DateTime? - - // Round-type-specific configuration (validated by Zod per RoundType) - configJson Json? @db.JsonB - - // Semantic analytics tag - purposeKey String? - - // Links to other entities - juryGroupId String? - submissionWindowId String? - specialAwardId String? - - // Finalization - gracePeriodEndsAt DateTime? - finalizedAt DateTime? - finalizedBy String? - - createdAt DateTime @default(now()) - 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) - projectRoundStates ProjectRoundState[] - visibleSubmissionWindows RoundSubmissionVisibility[] - assignmentIntents AssignmentIntent[] - deliberationSessions DeliberationSession[] - resultLocks ResultLock[] - submissionPromotions SubmissionPromotionEvent[] - specialAwards SpecialAward[] - - // Reverse relations from refitted models - evaluationForms EvaluationForm[] - fileRequirements FileRequirement[] - assignments Assignment[] - gracePeriods GracePeriod[] - liveVotingSession LiveVotingSession? - filteringRules FilteringRule[] - filteringResults FilteringResult[] - filteringJobs FilteringJob[] - assignmentJobs AssignmentJob[] - rankingSnapshots RankingSnapshot[] @relation("RoundRankingSnapshots") - reminderLogs ReminderLog[] - evaluationSummaries EvaluationSummary[] - evaluationDiscussions EvaluationDiscussion[] - messages Message[] - notificationLogs NotificationLog[] - cohorts Cohort[] - liveCursor LiveProgressCursor? - - @@unique([competitionId, slug]) - @@unique([competitionId, sortOrder]) - @@index([competitionId]) - @@index([roundType]) - @@index([status]) - @@index([specialAwardId]) -} - -model ProjectRoundState { - id String @id @default(cuid()) - projectId String - roundId String - state ProjectRoundStateValue @default(PENDING) - proposedOutcome ProjectRoundStateValue? - enteredAt DateTime @default(now()) - exitedAt DateTime? - metadataJson Json? @db.JsonB - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) - - @@unique([projectId, roundId]) - @@index([projectId]) - @@index([roundId]) - @@index([state]) - @@index([roundId, state]) -} - -// ============================================================================= -// JURY GROUP MODELS (NEW) -// ============================================================================= - -model JuryGroup { - id String @id @default(cuid()) - competitionId String - name String - slug String - description String? @db.Text - sortOrder Int @default(0) - - // Default assignment configuration - defaultMaxAssignments Int @default(20) - defaultCapMode CapMode @default(SOFT) - softCapBuffer Int @default(2) - - // Default category quotas - categoryQuotasEnabled Boolean @default(false) - defaultCategoryQuotas Json? @db.JsonB - - // Onboarding self-service - allowJurorCapAdjustment Boolean @default(false) - allowJurorRatioAdjustment Boolean @default(false) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade) - members JuryGroupMember[] - rounds Round[] - assignments Assignment[] - awards SpecialAward[] - - @@unique([competitionId, slug]) - @@index([competitionId]) -} - -model JuryGroupMember { - id String @id @default(cuid()) - juryGroupId String - userId String - role JuryGroupMemberRole @default(MEMBER) - joinedAt DateTime @default(now()) - - // Per-juror overrides (null = use group defaults) - maxAssignmentsOverride Int? - capModeOverride CapMode? - categoryQuotasOverride Json? @db.JsonB - - // Juror preferences (admin-set) - preferredStartupRatio Float? - availabilityNotes String? @db.Text - - // Self-service overrides (set by juror during onboarding, Layer 4b) - selfServiceCap Int? - selfServiceRatio Float? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - juryGroup JuryGroup @relation(fields: [juryGroupId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - assignmentIntents AssignmentIntent[] - deliberationVotes DeliberationVote[] - deliberationParticipations DeliberationParticipant[] - - @@unique([juryGroupId, userId]) - @@index([juryGroupId]) - @@index([userId]) -} - -// ============================================================================= -// SUBMISSION WINDOW MODELS (NEW) -// ============================================================================= - -model SubmissionWindow { - id String @id @default(cuid()) - competitionId String - name String - slug String - roundNumber Int - sortOrder Int @default(0) - - // Window timing - windowOpenAt DateTime? - windowCloseAt DateTime? - - // Deadline behavior - deadlinePolicy DeadlinePolicy @default(FLAG) - graceHours Int? - - // Locking behavior - lockOnClose Boolean @default(true) - isLocked Boolean @default(false) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade) - fileRequirements SubmissionFileRequirement[] - projectFiles ProjectFile[] - rounds Round[] - visibility RoundSubmissionVisibility[] - - @@unique([competitionId, slug]) - @@unique([competitionId, roundNumber]) - @@index([competitionId]) -} - -model SubmissionFileRequirement { - id String @id @default(cuid()) - submissionWindowId String - label String - slug String - description String? @db.Text - mimeTypes String[] - maxSizeMb Int? - required Boolean @default(true) - sortOrder Int @default(0) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - submissionWindow SubmissionWindow @relation(fields: [submissionWindowId], references: [id], onDelete: Cascade) - files ProjectFile[] - - @@unique([submissionWindowId, slug]) - @@index([submissionWindowId]) -} - -model RoundSubmissionVisibility { - id String @id @default(cuid()) - roundId String - submissionWindowId String - canView Boolean @default(true) - displayLabel String? - - // Relations - round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) - submissionWindow SubmissionWindow @relation(fields: [submissionWindowId], references: [id], onDelete: Cascade) - - @@unique([roundId, submissionWindowId]) - @@index([roundId]) -} - -// ============================================================================= -// ASSIGNMENT GOVERNANCE MODELS (NEW) -// ============================================================================= - -model AssignmentIntent { - id String @id @default(cuid()) - juryGroupMemberId String - roundId String - projectId String - source AssignmentIntentSource - status AssignmentIntentStatus @default(INTENT_PENDING) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - juryGroupMember JuryGroupMember @relation(fields: [juryGroupMemberId], references: [id], onDelete: Cascade) - round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - - @@unique([juryGroupMemberId, roundId, projectId]) - @@index([roundId]) - @@index([projectId]) - @@index([status]) -} - -// ============================================================================= -// MENTORING WORKSPACE MODELS (NEW) -// ============================================================================= - -model MentorFile { - 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 - mimeType String - size Int - bucket String - objectKey String - description String? @db.Text - - // Promotion to official submission - isPromoted Boolean @default(false) - promotedToFileId String? @unique - promotedAt DateTime? - promotedByUserId String? - - createdAt DateTime @default(now()) - - // Relations - 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]) -} - -model MentorFileComment { - id String @id @default(cuid()) - mentorFileId String - authorId String - content String @db.Text - - // Threading support - parentCommentId String? - - createdAt DateTime @default(now()) - 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) - replies MentorFileComment[] @relation("CommentThread") - - @@index([mentorFileId]) - @@index([authorId]) - @@index([parentCommentId]) -} - -model SubmissionPromotionEvent { - id String @id @default(cuid()) - projectId String - roundId String - slotKey String - sourceType SubmissionPromotionSource - sourceFileId String? - promotedById String - createdAt DateTime @default(now()) - - // Relations - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) - sourceFile MentorFile? @relation(fields: [sourceFileId], references: [id], onDelete: SetNull) - promotedBy User @relation("SubmissionPromoter", fields: [promotedById], references: [id]) - - @@index([projectId]) - @@index([roundId]) - @@index([sourceFileId]) -} - -// ============================================================================= -// DELIBERATION MODELS (NEW) -// ============================================================================= - -model DeliberationSession { - id String @id @default(cuid()) - competitionId String - roundId String - category CompetitionCategory - mode DeliberationMode - showCollectiveRankings Boolean @default(false) - showPriorJuryData Boolean @default(false) - status DeliberationStatus - tieBreakMethod TieBreakMethod - adminOverrideResult Json? @db.JsonB - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade) - round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) - votes DeliberationVote[] - results DeliberationResult[] - participants DeliberationParticipant[] - - @@index([competitionId]) - @@index([roundId]) - @@index([status]) -} - -model DeliberationVote { - id String @id @default(cuid()) - sessionId String - juryMemberId String - projectId String - rank Int? - isWinnerPick Boolean @default(false) - runoffRound Int @default(0) - createdAt DateTime @default(now()) - - // Relations - session DeliberationSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) - juryMember JuryGroupMember @relation(fields: [juryMemberId], references: [id], onDelete: Cascade) - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - - @@unique([sessionId, juryMemberId, projectId, runoffRound]) - @@index([sessionId]) - @@index([juryMemberId]) - @@index([projectId]) -} - -model DeliberationResult { - id String @id @default(cuid()) - sessionId String - projectId String - finalRank Int - voteCount Int @default(0) - isAdminOverridden Boolean @default(false) - overrideReason String? @db.Text - - // Relations - session DeliberationSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - - @@unique([sessionId, projectId]) - @@index([sessionId]) - @@index([projectId]) -} - -model DeliberationParticipant { - id String @id @default(cuid()) - sessionId String - userId String - status DeliberationParticipantStatus - replacedById String? - - // Relations - session DeliberationSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) - user JuryGroupMember @relation(fields: [userId], references: [id], onDelete: Cascade) - replacedBy User? @relation("DeliberationReplacement", fields: [replacedById], references: [id]) - - @@unique([sessionId, userId]) - @@index([sessionId]) - @@index([userId]) -} - -// ============================================================================= -// RESULT LOCKING MODELS (NEW) -// ============================================================================= - -model ResultLock { - id String @id @default(cuid()) - competitionId String - roundId String - category CompetitionCategory - lockedById String - resultSnapshot Json @db.JsonB - lockedAt DateTime @default(now()) - - // Relations - competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade) - round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) - lockedBy User @relation("ResultLockCreator", fields: [lockedById], references: [id]) - unlockEvents ResultUnlockEvent[] - - @@index([competitionId]) - @@index([roundId]) - @@index([category]) -} - -model ResultUnlockEvent { - id String @id @default(cuid()) - resultLockId String - unlockedById String - reason String @db.Text - unlockedAt DateTime @default(now()) - - // Relations - resultLock ResultLock @relation(fields: [resultLockId], references: [id], onDelete: Cascade) - unlockedBy User @relation("ResultUnlocker", fields: [unlockedById], references: [id]) - - @@index([resultLockId]) - @@index([unlockedById]) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Grand-finale logistics (PR 1: finalist confirmation flow) -// ───────────────────────────────────────────────────────────────────────────── - -enum WaitlistEntryStatus { - WAITING // available for promotion - PROMOTED // moved into a finalist slot - USED // promoted and confirmation flow completed (declined or accepted) -} - -enum FinalistConfirmationStatus { - PENDING // sent, awaiting team response - CONFIRMED // team accepted, attendees selected - DECLINED // team explicitly declined - EXPIRED // deadline passed without response - SUPERSEDED // admin manually overrode (e.g. unconfirmed to allow quota decrease) -} - -model FinalistSlotQuota { - id String @id @default(cuid()) - programId String - category CompetitionCategory - quota Int - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - program Program @relation(fields: [programId], references: [id], onDelete: Cascade) - - @@unique([programId, category]) - @@index([programId]) -} - -model WaitlistEntry { - id String @id @default(cuid()) - programId String - projectId String @unique - category CompetitionCategory - rank Int - status WaitlistEntryStatus @default(WAITING) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - program Program @relation(fields: [programId], references: [id], onDelete: Cascade) - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - - @@unique([programId, category, rank]) - @@index([programId, category, status]) -} - -model FinalistConfirmation { - id String @id @default(cuid()) - projectId String @unique - category CompetitionCategory - status FinalistConfirmationStatus @default(PENDING) - deadline DateTime - token String @unique - confirmedAt DateTime? - declinedAt DateTime? - declineReason String? // optional free-text on decline - expiredAt DateTime? - reminderSentAt DateTime? // set when the pre-deadline reminder is sent (cron) - finalDocsReminderSentAt DateTime? // set when the grand-final document-upload reminder is sent (cron) - promotedFromWaitlistEntryId String? @unique // null for original finalists - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - attendingMembers AttendingMember[] - - @@index([status, deadline]) // for cron scan - @@index([category, status]) -} - -model AttendingMember { - id String @id @default(cuid()) - confirmationId String - userId String // must be a TeamMember of the same project (validated at write time) - needsVisa Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - confirmation FinalistConfirmation @relation(fields: [confirmationId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - flightDetail FlightDetail? - visaApplication VisaApplication? - lunchPick MemberLunchPick? - hotelStay HotelStay? - - @@unique([confirmationId, userId]) - @@index([userId]) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Grand-finale logistics (PR 2: hotels + flight tracking) -// ───────────────────────────────────────────────────────────────────────────── - -enum FlightDetailStatus { - PENDING // team submitted details, admin not yet reviewed - CONFIRMED // admin verified booking -} - -model Hotel { - id String @id @default(cuid()) - programId String // many hotels per edition - name String - address String? @db.Text - link String? // external URL to hotel page / booking confirmation - notes String? @db.Text - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - program Program @relation(fields: [programId], references: [id], onDelete: Cascade) - stays HotelStay[] - - @@index([programId]) -} - -/// Per-attendee hotel/room assignment (1:1 with AttendingMember, mirrors FlightDetail). -model HotelStay { - id String @id @default(cuid()) - attendingMemberId String @unique - hotelId String - roomNumber String? - checkInAt DateTime? - checkOutAt DateTime? - notes String? @db.Text - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade) - hotel Hotel @relation(fields: [hotelId], references: [id], onDelete: Restrict) - - @@index([hotelId]) -} - -model FlightDetail { - id String @id @default(cuid()) - attendingMemberId String @unique // 1:1 - arrivalAt DateTime? - arrivalFlightNumber String? - arrivalAirport String? - departureAt DateTime? - departureFlightNumber String? - departureAirport String? - status FlightDetailStatus @default(PENDING) - adminNotes String? @db.Text - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade) - - @@index([status]) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Grand-finale visa tracking (PR 4) -// Process metadata only — no document storage. Passport scans / invitation -// letters / decision documents are exchanged over email; this model just -// records what stage the application is at, key dates, and free-text notes. -// ───────────────────────────────────────────────────────────────────────────── - -enum VisaStatus { - NOT_NEEDED - REQUESTED - INVITATION_SENT - APPOINTMENT_BOOKED - GRANTED - DENIED -} - -model VisaApplication { - id String @id @default(cuid()) - attendingMemberId String @unique // 1:1 - status VisaStatus @default(REQUESTED) - nationality String? // self-declared, optional - invitationSentAt DateTime? - appointmentAt DateTime? - decisionAt DateTime? // GRANTED or DENIED date - notes String? @db.Text - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade) - - @@index([status]) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Grand-finale lunch event (PR 6) -// Single configurable lunch event per edition. Each attending member has a -// 1:1 MemberLunchPick (auto-created via lunch-pick-sync). External attendees -// can be standalone or attached to a finalist project. Allergens use the -// EU 14 regulated list; dishes carry dietary tags. -// ───────────────────────────────────────────────────────────────────────────── - -enum DietaryTag { - VEGETARIAN - VEGAN - GLUTEN_FREE - PESCATARIAN -} - -enum Allergen { - GLUTEN - CRUSTACEANS - EGGS - FISH - PEANUTS - SOYBEANS - MILK - TREE_NUTS - CELERY - MUSTARD - SESAME - SULPHITES - LUPIN - MOLLUSCS -} - -model LunchEvent { - id String @id @default(cuid()) - programId String @unique - enabled Boolean @default(false) - eventAt DateTime? - endAt DateTime? - venue String? - notes String? @db.Text - changeCutoffHours Int @default(48) - reminderHoursBeforeDeadline Int? - cronEnabled Boolean @default(true) - extraRecipients String[] @default([]) - reminderSentAt DateTime? - recapSentAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - program Program @relation(fields: [programId], references: [id], onDelete: Cascade) - dishes Dish[] - externalAttendees ExternalAttendee[] -} - -model Dish { - id String @id @default(cuid()) - lunchEventId String - name String - sortOrder Int @default(0) - dietaryTags DietaryTag[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - lunchEvent LunchEvent @relation(fields: [lunchEventId], references: [id], onDelete: Cascade) - memberPicks MemberLunchPick[] - externals ExternalAttendee[] - - @@index([lunchEventId]) -} - -model MemberLunchPick { - id String @id @default(cuid()) - attendingMemberId String @unique - dishId String? - allergens Allergen[] @default([]) - allergenOther String? - pickedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade) - dish Dish? @relation(fields: [dishId], references: [id], onDelete: SetNull) - - @@index([dishId]) -} - -model ExternalAttendee { - id String @id @default(cuid()) - lunchEventId String - projectId String? - name String - email String? - roleNote String? - dishId String? - allergens Allergen[] @default([]) - allergenOther String? - inviteSentAt DateTime? // when the dish-selection email was last sent (null = never invited) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - lunchEvent LunchEvent @relation(fields: [lunchEventId], references: [id], onDelete: Cascade) - project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) - dish Dish? @relation(fields: [dishId], references: [id], onDelete: SetNull) - - @@index([lunchEventId]) - @@index([projectId]) -} +// ============================================================================= +// MOPC Platform - Prisma Schema +// ============================================================================= +// This schema defines the database structure for the Monaco Ocean Protection +// Challenge jury voting platform. + +generator client { + provider = "prisma-client-js" + binaryTargets = ["native", "windows", "linux-musl-openssl-3.0.x"] +} + +datasource db { + provider = "postgresql" + // connection_limit and pool_timeout are set via query params in DATABASE_URL: + // ?connection_limit=10&pool_timeout=30 + // Defaults: connection_limit = num_cpus * 2 + 1, pool_timeout = 10s. + // Override in .env for production to prevent connection exhaustion. + url = env("DATABASE_URL") +} + +// ============================================================================= +// ENUMS +// ============================================================================= + +enum UserRole { + SUPER_ADMIN + PROGRAM_ADMIN + JURY_MEMBER + MENTOR + OBSERVER + APPLICANT + AUDIENCE +} + +enum UserStatus { + NONE + INVITED + ACTIVE + SUSPENDED +} + +enum ProgramStatus { + DRAFT + ACTIVE + ARCHIVED +} + +enum ProjectStatus { + SUBMITTED + ELIGIBLE + ASSIGNED + SEMIFINALIST + FINALIST + REJECTED +} + +enum EvaluationStatus { + NOT_STARTED + DRAFT + SUBMITTED + LOCKED +} + +enum AssignmentMethod { + MANUAL + BULK + AI_SUGGESTED + AI_AUTO + ALGORITHM +} + +enum FileType { + EXEC_SUMMARY + PRESENTATION + VIDEO + OTHER + BUSINESS_PLAN + VIDEO_PITCH + SUPPORTING_DOC +} + +enum SubmissionSource { + MANUAL + CSV + NOTION + TYPEFORM + PUBLIC_FORM +} + +enum SettingType { + STRING + NUMBER + BOOLEAN + JSON + SECRET +} + +enum SettingCategory { + AI + BRANDING + EMAIL + STORAGE + SECURITY + DEFAULTS + WHATSAPP + AUDIT_CONFIG + DIGEST + ANALYTICS + INTEGRATIONS + COMMUNICATION + FEATURE_FLAGS +} + +enum NotificationChannel { + EMAIL + WHATSAPP + BOTH + NONE +} + +enum PartnerVisibility { + ADMIN_ONLY + JURY_VISIBLE + PUBLIC +} + +enum PartnerType { + SPONSOR + PARTNER + SUPPORTER + MEDIA + OTHER +} + +// ============================================================================= +// COMPETITION / ROUND ENGINE ENUMS +// ============================================================================= + +enum CompetitionStatus { + DRAFT + ACTIVE + CLOSED + ARCHIVED +} + +enum RoundType { + INTAKE + FILTERING + EVALUATION + SUBMISSION + MENTORING + LIVE_FINAL + DELIBERATION +} + +enum RoundStatus { + ROUND_DRAFT + ROUND_ACTIVE + ROUND_CLOSED + ROUND_ARCHIVED +} + +enum ProjectRoundStateValue { + PENDING + IN_PROGRESS + PASSED + REJECTED + COMPLETED + WITHDRAWN +} + +enum CapMode { + HARD + SOFT + NONE +} + +enum DeadlinePolicy { + HARD_DEADLINE + FLAG + GRACE +} + +enum JuryGroupMemberRole { + CHAIR + MEMBER + OBSERVER +} + +enum AssignmentIntentSource { + INVITE + ADMIN + SYSTEM +} + +enum AssignmentIntentStatus { + INTENT_PENDING + HONORED + OVERRIDDEN + EXPIRED + CANCELLED +} + +enum MentorMessageRole { + MENTOR_ROLE + APPLICANT_ROLE + ADMIN_ROLE +} + +enum SubmissionPromotionSource { + MENTOR_FILE + ADMIN_REPLACEMENT +} + +enum DeliberationMode { + SINGLE_WINNER_VOTE + FULL_RANKING +} + +enum DeliberationStatus { + DELIB_OPEN + VOTING + TALLYING + RUNOFF + DELIB_LOCKED +} + +enum TieBreakMethod { + TIE_RUNOFF + TIE_ADMIN_DECIDES + SCORE_FALLBACK +} + +enum DeliberationParticipantStatus { + REQUIRED + ABSENT_EXCUSED + REPLACED + REPLACEMENT_ACTIVE +} + +enum AwardEligibilityMode { + SEPARATE_POOL + STAY_IN_MAIN +} + +// ============================================================================= +// APPLICANT SYSTEM ENUMS +// ============================================================================= + +enum CompetitionCategory { + STARTUP // Existing companies + BUSINESS_CONCEPT // Students/graduates +} + +enum OceanIssue { + POLLUTION_REDUCTION + CLIMATE_MITIGATION + TECHNOLOGY_INNOVATION + SUSTAINABLE_SHIPPING + BLUE_CARBON + HABITAT_RESTORATION + COMMUNITY_CAPACITY + SUSTAINABLE_FISHING + CONSUMER_AWARENESS + OCEAN_ACIDIFICATION + OTHER +} + +enum TeamMemberRole { + LEAD // Primary contact / team lead + MEMBER // Regular team member + ADVISOR // Advisor/mentor from team side +} + +enum MentorAssignmentMethod { + MANUAL + AI_SUGGESTED + AI_AUTO + ALGORITHM +} + +// ============================================================================= +// USERS & AUTHENTICATION +// ============================================================================= + +model User { + id String @id @default(cuid()) + email String @unique + name String? + emailVerified DateTime? // Required by NextAuth Prisma adapter + role UserRole @default(APPLICANT) + roles UserRole[] @default([]) + status UserStatus @default(INVITED) + expertiseTags String[] @default([]) + maxAssignments Int? // Per-round limit + country String? // User's home country (for mentor matching) + nationality String? // User's nationality (for applicant profiles) + institution String? // User's institution/organization + metadataJson Json? @db.JsonB + + // Mentor onboarding email idempotency: stamped once when MENTOR role is first added. + mentorOnboardingSentAt DateTime? + + // Profile + bio String? // User bio for matching with project descriptions + profileImageKey String? // Storage key (e.g., "avatars/user123/1234567890.jpg") + profileImageProvider String? // Storage provider used: 's3' or 'local' + + // Phone and notification preferences (Phase 2) + phoneNumber String? + phoneNumberVerified Boolean @default(false) + notificationPreference NotificationChannel @default(EMAIL) + whatsappOptIn Boolean @default(false) + + // Onboarding (Phase 2B) + onboardingCompletedAt DateTime? + + // Password authentication (hybrid auth) + passwordHash String? // bcrypt hashed password + passwordSetAt DateTime? // When password was set + mustSetPassword Boolean @default(true) // Force setup on first login + + // Invitation token for one-click invite acceptance + inviteToken String? @unique + inviteTokenExpiresAt DateTime? + + // Password reset token + passwordResetToken String? @unique + passwordResetExpiresAt DateTime? + + // Digest & availability preferences + digestFrequency String @default("none") // 'none' | 'daily' | 'weekly' + preferredWorkload Int? + availabilityJson Json? @db.JsonB // { startDate?: string, endDate?: string } + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastLoginAt DateTime? + + // Relations + assignments Assignment[] + auditLogs AuditLog[] + gracePeriods GracePeriod[] + grantedGracePeriods GracePeriod[] @relation("GrantedBy") + notificationLogs NotificationLog[] + createdResources LearningResource[] @relation("ResourceCreatedBy") + resourceAccess ResourceAccess[] + submittedProjects Project[] @relation("ProjectSubmittedBy") + liveVotes LiveVote[] + liveNotes LiveNote[] + + // Team membership & mentorship + teamMemberships TeamMember[] + mentorAssignments MentorAssignment[] @relation("MentorAssignments") + + // Awards + awardJurorships AwardJuror[] + awardVotes AwardVote[] + + // Filtering overrides + filteringOverrides FilteringResult[] @relation("FilteringOverriddenBy") + + // Award overrides + awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy") + awardEligibilityConfirms AwardEligibility[] @relation("AwardEligibilityConfirmer") + awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy") + + // In-app notifications + notifications InAppNotification[] @relation("UserNotifications") + notificationSettingsUpdated NotificationEmailSetting[] @relation("NotificationSettingUpdater") + + // Reminder logs + reminderLogs ReminderLog[] + + // Conflict of interest + conflictsOfInterest ConflictOfInterest[] + coiReviews ConflictOfInterest[] @relation("COIReviewedBy") + + // Evaluation summaries + generatedSummaries EvaluationSummary[] @relation("EvaluationSummaryGeneratedBy") + + // Mentor messages + mentorMessages MentorMessage[] @relation("MentorMessageSender") + + // Wizard templates + wizardTemplates WizardTemplate[] @relation("WizardTemplateCreatedBy") + + // Mentor notes + mentorNotes MentorNote[] @relation("MentorNoteAuthor") + + // Milestone completions + milestoneCompletions MentorMilestoneCompletion[] @relation("MilestoneCompletedBy") + + // Evaluation discussions + closedDiscussions EvaluationDiscussion[] @relation("DiscussionClosedBy") + discussionComments DiscussionComment[] @relation("DiscussionCommentAuthor") + + // Messaging + sentMessages Message[] @relation("MessageSender") + receivedMessages MessageRecipient[] @relation("MessageRecipient") + messageTemplates MessageTemplate[] @relation("MessageTemplateCreator") + + // Webhooks + webhooks Webhook[] @relation("WebhookCreator") + + // Digest logs + digestLogs DigestLog[] @relation("DigestLog") + + // NextAuth relations + accounts Account[] + 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") + + // AI Ranking + rankingSnapshots RankingSnapshot[] @relation("TriggeredRankingSnapshots") + + // Grand-finale logistics + finalistAttendances AttendingMember[] + + // Mentor change requests + mentorChangeRequestsRequested MentorChangeRequest[] @relation("MentorChangeRequester") + mentorChangeRequestsResolved MentorChangeRequest[] @relation("MentorChangeResolver") + + @@index([role]) + @@index([status]) +} + +// NextAuth.js required models +model Account { + id String @id @default(cuid()) + userId String + type String + provider String + providerAccountId String + refresh_token String? @db.Text + access_token String? @db.Text + expires_at Int? + token_type String? + scope String? + id_token String? @db.Text + session_state String? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) + @@index([userId]) +} + +model Session { + id String @id @default(cuid()) + sessionToken String @unique + userId String + expires DateTime + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) +} + +model VerificationToken { + identifier String + token String @unique + expires DateTime + + @@unique([identifier, token]) +} + +// ============================================================================= +// PROGRAMS & ROUNDS +// ============================================================================= + +model Program { + id String @id @default(cuid()) + name String // e.g., "Monaco Ocean Protection Challenge" + slug String? @unique // URL-friendly identifier for edition-wide applications + year Int // e.g., 2026 + status ProgramStatus @default(DRAFT) + description String? + settingsJson Json? @db.JsonB + + // Grand-finale logistics + defaultAttendeeCap Int @default(3) // Max team members allowed at the grand finale per finalist team + visaStatusVisibleToMembers Boolean @default(true) // Whether team members see their own visa status on the applicant dashboard + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + projects Project[] + learningResources LearningResource[] + partners Partner[] + specialAwards SpecialAward[] + taggingJobs TaggingJob[] + wizardTemplates WizardTemplate[] + mentorMilestones MentorMilestone[] + competitions Competition[] + + // Grand-finale logistics + finalistSlotQuotas FinalistSlotQuota[] + waitlistEntries WaitlistEntry[] + hotels Hotel[] + lunchEvent LunchEvent? + + @@unique([name, year]) + @@index([status]) +} + +model WizardTemplate { + id String @id @default(cuid()) + name String + description String? + config Json @db.JsonB + isGlobal Boolean @default(false) + programId String? + program Program? @relation(fields: [programId], references: [id], onDelete: Cascade) + createdBy String + creator User @relation("WizardTemplateCreatedBy", fields: [createdBy], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([programId]) + @@index([isGlobal]) +} + +// ============================================================================= +// EVALUATION FORMS +// ============================================================================= + +model EvaluationForm { + id String @id @default(cuid()) + roundId String + version Int @default(1) + category CompetitionCategory? // null=shared form, STARTUP or BUSINESS_CONCEPT=category-specific + + // Form configuration + // criteriaJson: Array of { id, label, description, scale, weight, required } + criteriaJson Json @db.JsonB + // scalesJson: { "1-5": { min, max, labels }, "1-10": { min, max, labels } } + scalesJson Json? @db.JsonB + isActive Boolean @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + evaluations Evaluation[] + + @@unique([roundId, version, category]) + @@index([roundId, isActive]) + @@index([roundId, isActive, category]) +} + +// ============================================================================= +// PROJECTS +// ============================================================================= + +model Project { + id String @id @default(cuid()) + programId String + status ProjectStatus @default(SUBMITTED) + + // Core fields + title String + teamName String? + description String? @db.Text + + // Competition category + competitionCategory CompetitionCategory? + oceanIssue OceanIssue? + + // Location + country String? + geographicZone String? // "Europe, France" + + // Institution (for students/Business Concepts) + institution String? + + // Mentorship + wantsMentorship Boolean @default(false) + + // Founding date + foundedAt DateTime? // When the project/company was founded + + // Submission links (external, from CSV) + phase1SubmissionUrl String? + phase2SubmissionUrl String? + + // Referral tracking + referralSource String? + + // Internal admin fields + internalComments String? @db.Text + applicationStatus String? // "Received", etc. + + // Submission tracking + submissionSource SubmissionSource @default(MANUAL) + submittedByEmail String? + submittedAt DateTime? + submittedByUserId String? + + // Project branding + logoKey String? // Storage key (e.g., "logos/project456/1234567890.png") + logoProvider String? // Storage provider used: 's3' or 'local' + + // Draft support + isDraft Boolean @default(false) + draftDataJson Json? @db.JsonB // Form data for drafts + draftExpiresAt DateTime? + + // Flexible fields + tags String[] @default([]) // "Ocean Conservation", "Tech", etc. + metadataJson Json? @db.JsonB // Custom fields from Typeform, etc. + externalIdsJson Json? @db.JsonB // Typeform ID, Notion ID, etc. + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + program Program @relation(fields: [programId], references: [id], onDelete: Cascade) + files ProjectFile[] + assignments Assignment[] + submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull) + teamMembers TeamMember[] + mentorAssignments MentorAssignment[] + mentorFiles MentorFile[] + mentorChangeRequests MentorChangeRequest[] + filteringResults FilteringResult[] + awardEligibilities AwardEligibility[] + awardVotes AwardVote[] + wonAwards SpecialAward[] @relation("AwardWinner") + projectTags ProjectTag[] + statusHistory ProjectStatusHistory[] + mentorMessages MentorMessage[] + evaluationSummaries EvaluationSummary[] + evaluationDiscussions EvaluationDiscussion[] + cohortProjects CohortProject[] + + // ── Competition/Round architecture relations ── + projectRoundStates ProjectRoundState[] + assignmentIntents AssignmentIntent[] + deliberationVotes DeliberationVote[] + deliberationResults DeliberationResult[] + submissionPromotions SubmissionPromotionEvent[] + notificationLogs NotificationLog[] + + // Grand-finale logistics + waitlistEntry WaitlistEntry? + finalistConfirmation FinalistConfirmation? + externalLunchAttendees ExternalAttendee[] + + // Grand-finale ceremony + audienceFavoriteVotes AudienceFavoriteVote[] + liveNotes LiveNote[] + + @@index([programId]) + @@index([status]) + @@index([tags]) + @@index([submissionSource]) + @@index([submittedByUserId]) + @@index([competitionCategory]) + @@index([oceanIssue]) + @@index([country]) +} + +model FileRequirement { + id String @id @default(cuid()) + roundId String + name String + description String? + acceptedMimeTypes String[] // e.g. ["application/pdf", "video/*"] + maxSizeMB Int? // Max file size in MB + isRequired Boolean @default(true) + sortOrder Int @default(0) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + files ProjectFile[] + + @@index([roundId]) +} + +model ProjectFile { + id String @id @default(cuid()) + projectId String + roundId String? // Which round this file was submitted for + requirementId String? // FK to FileRequirement (if uploaded against a requirement) + + // File info + fileType FileType + fileName String + mimeType String + size Int // bytes + pageCount Int? // Number of pages (PDFs, presentations, etc.) + + // 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 + + // MinIO location + bucket String + objectKey String + + isLate Boolean @default(false) // Uploaded after round deadline + + // Versioning + version Int @default(1) + replacedById String? // FK to the newer file that replaced this one + + // ── Competition/Round architecture fields ── + submissionWindowId String? // FK to SubmissionWindow + submissionFileRequirementId String? // FK to SubmissionFileRequirement + + createdAt DateTime @default(now()) + + // Relations + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + requirement FileRequirement? @relation(fields: [requirementId], references: [id], onDelete: SetNull) + replacedBy ProjectFile? @relation("FileVersions", fields: [replacedById], references: [id], onDelete: SetNull) + replacements ProjectFile[] @relation("FileVersions") + submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull) + submissionFileRequirement SubmissionFileRequirement? @relation(fields: [submissionFileRequirementId], references: [id], onDelete: SetNull) + promotedFrom MentorFile? @relation("PromotedFromMentorFile") + + @@unique([bucket, objectKey]) + @@index([projectId]) + @@index([fileType]) + @@index([requirementId]) + @@index([submissionWindowId]) + @@index([submissionFileRequirementId]) +} + +// ============================================================================= +// ASSIGNMENTS & EVALUATIONS +// ============================================================================= + +model Assignment { + id String @id @default(cuid()) + userId String + projectId String + roundId String + + // Assignment info + method AssignmentMethod @default(MANUAL) + isRequired Boolean @default(true) + isCompleted Boolean @default(false) + + // AI assignment metadata + aiConfidenceScore Float? // 0-1 confidence from AI + expertiseMatchScore Float? // 0-1 match score + aiReasoning String? @db.Text + + createdAt DateTime @default(now()) + createdBy String? // Admin who created the assignment + + // Competition/Round architecture — jury group link + 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) + evaluation Evaluation? + conflictOfInterest ConflictOfInterest? + + @@unique([userId, projectId, roundId]) + @@index([roundId]) + @@index([userId]) + @@index([projectId]) + @@index([isCompleted]) + @@index([projectId, userId]) + @@index([juryGroupId]) + @@index([roundId, isCompleted]) +} + +model Evaluation { + id String @id @default(cuid()) + assignmentId String @unique + formId String + + // Status + status EvaluationStatus @default(NOT_STARTED) + + // Scores + // criterionScoresJson: { "criterion_id": score, ... } + criterionScoresJson Json? @db.JsonB + globalScore Int? // 1-10 + binaryDecision Boolean? // Yes/No for semi-finalist + feedbackText String? @db.Text + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + submittedAt DateTime? + + // Relations + assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade) + form EvaluationForm @relation(fields: [formId], references: [id], onDelete: Cascade) + + @@index([status]) + @@index([submittedAt]) + @@index([formId]) + @@index([status, formId]) +} + +// ============================================================================= +// GRACE PERIODS +// ============================================================================= + +model GracePeriod { + id String @id @default(cuid()) + roundId String + userId String + projectId String? // Optional: specific project or all projects in round + + extendedUntil DateTime + reason String? @db.Text + grantedById String + + createdAt DateTime @default(now()) + + // Relations + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + grantedBy User @relation("GrantedBy", fields: [grantedById], references: [id]) + + @@index([roundId]) + @@index([userId]) + @@index([extendedUntil]) + @@index([grantedById]) + @@index([projectId]) + @@index([roundId, userId, extendedUntil]) +} + +// ============================================================================= +// SYSTEM SETTINGS +// ============================================================================= + +model SystemSettings { + id String @id @default(cuid()) + key String @unique + value String @db.Text + type SettingType @default(STRING) + category SettingCategory + + description String? + isSecret Boolean @default(false) // If true, value is encrypted + + updatedAt DateTime @updatedAt + updatedBy String? + + @@index([category]) +} + +// ============================================================================= +// AUDIT LOGGING +// ============================================================================= + +model AuditLog { + id String @id @default(cuid()) + userId String? + + // Event info + action String // "CREATE", "UPDATE", "DELETE", "LOGIN", "EXPORT", etc. + entityType String // "Round", "Project", "Evaluation", etc. + entityId String? + + // Details + detailsJson Json? @db.JsonB // Before/after values, additional context + previousDataJson Json? @db.JsonB // Previous state for tracking changes + + // Request info + ipAddress String? + userAgent String? + sessionId String? + + timestamp DateTime @default(now()) + + // Relations + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + @@index([userId]) + @@index([action]) + @@index([entityType, entityId]) + @@index([timestamp]) + @@index([entityType, entityId, timestamp]) + @@index([sessionId]) +} + +// ============================================================================= +// AI USAGE TRACKING +// ============================================================================= + +model AIUsageLog { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + + // Who/what triggered it + userId String? + action String // ASSIGNMENT, FILTERING, AWARD_ELIGIBILITY, MENTOR_MATCHING + entityType String? // Round, Project, Award + entityId String? + + // What was used + model String // gpt-4o, gpt-4o-mini, o1, claude-sonnet-4-5, etc. + provider String? // openai, anthropic, litellm + promptTokens Int + completionTokens Int + totalTokens Int + + // Cost tracking + estimatedCostUsd Decimal? @db.Decimal(10, 6) + + // Request context + batchSize Int? + itemsProcessed Int? + + // Status + status String // SUCCESS, PARTIAL, ERROR + errorMessage String? + + // Detailed data (optional) + detailsJson Json? @db.JsonB + + @@index([userId]) + @@index([action]) + @@index([createdAt]) + @@index([model]) +} + +// ============================================================================= +// NOTIFICATION LOG (Phase 2) +// ============================================================================= + +model NotificationLog { + id String @id @default(cuid()) + userId String? + channel NotificationChannel @default(EMAIL) + provider String? // META, TWILIO, SMTP + type String // MAGIC_LINK, REMINDER, ANNOUNCEMENT, JURY_INVITATION, ADVANCEMENT_NOTIFICATION, etc. + status String // PENDING, SENT, DELIVERED, FAILED + externalId String? // Message ID from provider + errorMsg String? @db.Text + + // Bulk notification tracking + email String? // Recipient email address + roundId String? + projectId String? + batchId String? // Groups emails from same send operation + + createdAt DateTime @default(now()) + + // Relations + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + round Round? @relation(fields: [roundId], references: [id], onDelete: SetNull) + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) + + @@index([userId]) + @@index([status]) + @@index([createdAt]) + @@index([roundId, type]) + @@index([projectId]) + @@index([batchId]) + @@index([email]) + @@index([type, status]) +} + +// ============================================================================= +// IN-APP NOTIFICATIONS +// ============================================================================= + +model InAppNotification { + id String @id @default(cuid()) + userId String + type String // FILTERING_COMPLETE, NEW_APPLICATION, ASSIGNED_TO_PROJECT, etc. + priority String @default("normal") // low, normal, high, urgent + icon String? // lucide icon name + title String + message String @db.Text + linkUrl String? // Where to navigate when clicked + linkLabel String? // CTA text + metadata Json? @db.JsonB // Extra context (projectId, roundId, etc.) + groupKey String? // For batching similar notifications + + isRead Boolean @default(false) + readAt DateTime? + expiresAt DateTime? // Auto-dismiss after date + + createdAt DateTime @default(now()) + + // Relations + user User @relation("UserNotifications", fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, isRead]) + @@index([userId, createdAt]) + @@index([groupKey]) +} + +model NotificationEmailSetting { + id String @id @default(cuid()) + notificationType String @unique // e.g., "ADVANCED_TO_ROUND", "ASSIGNED_TO_PROJECT" + category String // "team", "jury", "mentor", "admin" + label String // Human-readable label for admin UI + description String? // Help text + sendEmail Boolean @default(true) + emailSubject String? // Custom subject template (optional) + emailTemplate String? @db.Text // Custom body template (optional) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + updatedById String? + updatedBy User? @relation("NotificationSettingUpdater", fields: [updatedById], references: [id]) + + @@index([category]) +} + +// ============================================================================= +// LEARNING HUB (Phase 2) +// ============================================================================= + +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 + + // File storage (for uploaded resources) + fileName String? + mimeType String? + size Int? + bucket String? + objectKey String? + + // Cover image (stored in MinIO) + coverImageKey String? + + // External link + externalUrl String? + + sortOrder Int @default(0) + isPublished Boolean @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + createdById String + + // Relations + program Program? @relation(fields: [programId], references: [id], onDelete: SetNull) + createdBy User @relation("ResourceCreatedBy", fields: [createdById], references: [id]) + accessLogs ResourceAccess[] + + @@index([programId]) + @@index([isPublished]) + @@index([sortOrder]) +} + +model ResourceAccess { + id String @id @default(cuid()) + resourceId String + userId String + accessedAt DateTime @default(now()) + ipAddress String? + + // Relations + resource LearningResource @relation(fields: [resourceId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([resourceId]) + @@index([userId]) + @@index([accessedAt]) +} + +// ============================================================================= +// PARTNER MANAGEMENT (Phase 2) +// ============================================================================= + +model Partner { + id String @id @default(cuid()) + programId String? // null = global partner + name String + description String? @db.Text + website String? + partnerType PartnerType @default(PARTNER) + visibility PartnerVisibility @default(ADMIN_ONLY) + + // Logo file + logoFileName String? + logoBucket String? + logoObjectKey String? + + sortOrder Int @default(0) + isActive Boolean @default(true) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + program Program? @relation(fields: [programId], references: [id], onDelete: SetNull) + + @@index([programId]) + @@index([partnerType]) + @@index([visibility]) + @@index([isActive]) + @@index([sortOrder]) +} + +// ============================================================================= +// EXPERTISE TAGS (Phase 2B) +// ============================================================================= + +model ExpertiseTag { + id String @id @default(cuid()) + name String @unique + description String? + category String? // "Marine Science", "Technology", "Policy" + color String? // Hex for badge + isActive Boolean @default(true) + sortOrder Int @default(0) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + projectTags ProjectTag[] + + @@index([category]) + @@index([isActive]) + @@index([sortOrder]) +} + +// Project-Tag relationship for AI tagging +model ProjectTag { + id String @id @default(cuid()) + projectId String + tagId String + confidence Float @default(1.0) // AI confidence score 0-1 + source String @default("AI") // "AI" or "MANUAL" + createdAt DateTime @default(now()) + + // Relations + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + tag ExpertiseTag @relation(fields: [tagId], references: [id], onDelete: Cascade) + + @@unique([projectId, tagId]) + @@index([projectId]) + @@index([tagId]) +} + +// ============================================================================= +// LIVE VOTING (Phase 2B) +// ============================================================================= + +model LiveVotingSession { + id String @id @default(cuid()) + roundId String? @unique + status String @default("NOT_STARTED") // NOT_STARTED, IN_PROGRESS, PAUSED, COMPLETED + currentProjectIndex Int @default(0) + currentProjectId String? + votingStartedAt DateTime? + votingEndsAt DateTime? + projectOrderJson Json? @db.JsonB // Array of project IDs in presentation order + + // Criteria-based voting + votingMode String @default("simple") // "simple" (1-10) | "criteria" (per-criterion scores) + criteriaJson Json? @db.JsonB // Array of { id, label, description, scale, weight } + + // Audience & presentation settings + allowAudienceVotes Boolean @default(false) + audienceVoteWeight Float @default(0) // 0.0 to 1.0 + tieBreakerMethod String @default("admin_decides") // 'admin_decides' | 'highest_individual' | 'revote' + presentationSettingsJson Json? @db.JsonB + + // Audience voting configuration + audienceVotingMode String @default("disabled") // "disabled" | "per_project" | "per_category" | "favorites" + audienceMaxFavorites Int @default(3) // For "favorites" mode + audienceRequireId Boolean @default(false) // Require email/phone for audience + audienceVotingDuration Int? // Minutes (null = same as jury) + + // Audience favorite-vote window (grand finale) + audiencePhase AudiencePhase @default(CLOSED) + audienceWindowKey String? // 'CATEGORY:STARTUP' | 'CATEGORY:BUSINESS_CONCEPT' | 'OVERALL' + audienceWindowOpenedAt DateTime? + audienceWindowClosesAt DateTime? + allowOverallFavorite Boolean @default(false) // admin toggle, decided day-of + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + round Round? @relation(fields: [roundId], references: [id], onDelete: Cascade) + votes LiveVote[] + audienceVoters AudienceVoter[] + favoriteVotes AudienceFavoriteVote[] + revealState RevealState? + + @@index([status]) +} + +model LiveVote { + id String @id @default(cuid()) + sessionId String + projectId String + userId String? // Nullable for audience voters without accounts + score Int // 1-10 (or weighted score for criteria mode) + isAudienceVote Boolean @default(false) + votedAt DateTime @default(now()) + + // Criteria scores (used when votingMode="criteria") + criterionScoresJson Json? @db.JsonB // { [criterionId]: score } - null for simple mode + + // Optional overall comment from the juror (grand finale) + comment String? @db.Text + + // Audience voter link + audienceVoterId String? + + // Relations + session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + audienceVoter AudienceVoter? @relation(fields: [audienceVoterId], references: [id], onDelete: Cascade) + + @@unique([sessionId, projectId, userId]) + @@unique([sessionId, projectId, audienceVoterId]) + @@index([sessionId]) + @@index([projectId]) + @@index([userId]) + @@index([audienceVoterId]) +} + +model AudienceVoter { + id String @id @default(cuid()) + sessionId String + token String @unique // Unique voting token (UUID) + identifier String? // Optional: email, phone, or name + identifierType String? // "email" | "phone" | "name" | "anonymous" + ipAddress String? + userAgent String? + createdAt DateTime @default(now()) + + // Relations + session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + votes LiveVote[] + favoriteVotes AudienceFavoriteVote[] + + @@index([sessionId]) + @@index([token]) +} + +// One pick-one-favorite vote per audience member per voting window. +// windowKey snapshots LiveVotingSession.audienceWindowKey at cast time so +// per-category and overall votes coexist in one table. +model AudienceFavoriteVote { + id String @id @default(cuid()) + sessionId String + windowKey String // 'CATEGORY:STARTUP' | 'CATEGORY:BUSINESS_CONCEPT' | 'OVERALL' + projectId String + audienceVoterId String + ipAddress String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + audienceVoter AudienceVoter @relation(fields: [audienceVoterId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([sessionId, windowKey, audienceVoterId]) + @@index([sessionId, windowKey, ipAddress]) + @@index([sessionId, windowKey, projectId]) +} + +// Per-juror per-project free-text notes taken during the live ceremony. +// Resurfaces during deliberation. +model LiveNote { + id String @id @default(cuid()) + roundId String + projectId String + userId String + content String @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([roundId, projectId, userId]) + @@index([userId]) +} + +// Admin-driven results reveal for the big-screen ceremony view. +// Steps beyond currentStepIndex are never exposed publicly. +model RevealState { + id String @id @default(cuid()) + sessionId String @unique + status String @default("DRAFT") // DRAFT | ARMED | REVEALING | DONE + stepsJson Json @db.JsonB // RevealStep[] + currentStepIndex Int @default(-1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) +} + +enum LivePhase { + ON_DECK + PRESENTING + QA + SCORING +} + +enum AudiencePhase { + CLOSED + OPEN +} + +// ============================================================================= +// TEAM MEMBERSHIP +// ============================================================================= + +model TeamMember { + id String @id @default(cuid()) + projectId String + userId String + role TeamMemberRole @default(MEMBER) + title String? // "CEO", "CTO", etc. + joinedAt DateTime @default(now()) + + // Relations + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([projectId, userId]) + @@index([projectId]) + @@index([userId]) + @@index([role]) +} + +// ============================================================================= +// MENTOR ASSIGNMENT +// ============================================================================= + +model MentorAssignment { + id String @id @default(cuid()) + projectId String // Team can have multiple mentors; uniqueness enforced via composite below + mentorId String // User with MENTOR role or expertise + + // Assignment tracking + method MentorAssignmentMethod @default(MANUAL) + assignedAt DateTime @default(now()) + assignedBy String? // Admin who assigned + + // Per-assignment email idempotency: stamped once the MENTOR-side notification + // email has been sent (the "you've been assigned a project" email to the mentor). + notificationSentAt DateTime? + + // Stamped once the TEAM has been introduced to this mentor (the "meet your + // mentor" email with mentor contact info). Fired by `activateRound` for + // MENTORING rounds and by mentor.assign when the project's MENTORING round + // is already ROUND_ACTIVE. Independent from notificationSentAt above. + teamIntroducedAt DateTime? + + // AI assignment metadata + aiConfidenceScore Float? + expertiseMatchScore Float? + aiReasoning String? @db.Text + + // Tracking + completionStatus String @default("in_progress") // 'in_progress' | 'completed' | 'paused' + lastViewedAt DateTime? + + // Drop tracking — null while assignment is active + droppedAt DateTime? + droppedReason String? @db.Text + droppedBy String? // 'mentor' | 'admin' | 'finalist_unconfirmed' + + // ── Competition/Round architecture — workspace activation ── + workspaceEnabled Boolean @default(false) + workspaceOpenAt DateTime? + workspaceCloseAt DateTime? + + // Relations + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + mentor User @relation("MentorAssignments", fields: [mentorId], references: [id]) + notes MentorNote[] + 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 +// ============================================================================= + +enum FilteringOutcome { + PASSED + FILTERED_OUT + FLAGGED +} + +enum FilteringRuleType { + FIELD_BASED + DOCUMENT_CHECK + AI_SCREENING +} + +model FilteringRule { + id String @id @default(cuid()) + roundId String + name String + ruleType FilteringRuleType + configJson Json @db.JsonB // Conditions, logic, action per rule type + priority Int @default(0) + isActive Boolean @default(true) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + + @@index([roundId]) + @@index([priority]) +} + +model FilteringResult { + id String @id @default(cuid()) + roundId String + projectId String + outcome FilteringOutcome + ruleResultsJson Json? @db.JsonB // Per-rule results + aiScreeningJson Json? @db.JsonB // AI screening details + + // Admin override + overriddenBy String? + overriddenAt DateTime? + overrideReason String? @db.Text + finalOutcome FilteringOutcome? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + overriddenByUser User? @relation("FilteringOverriddenBy", fields: [overriddenBy], references: [id], onDelete: SetNull) + + @@unique([roundId, projectId]) + @@index([roundId]) + @@index([projectId]) + @@index([outcome]) +} + +// Tracks progress of long-running filtering jobs +model FilteringJob { + id String @id @default(cuid()) + roundId String + status FilteringJobStatus @default(PENDING) + totalProjects Int @default(0) + totalBatches Int @default(0) + currentBatch Int @default(0) + processedCount Int @default(0) + passedCount Int @default(0) + filteredCount Int @default(0) + flaggedCount Int @default(0) + errorMessage String? @db.Text + startedAt DateTime? + completedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + + @@index([roundId]) + @@index([status]) +} + +enum FilteringJobStatus { + PENDING + RUNNING + COMPLETED + FAILED +} + +// Tracks progress of long-running AI assignment jobs +model AssignmentJob { + id String @id @default(cuid()) + roundId String + status AssignmentJobStatus @default(PENDING) + totalProjects Int @default(0) + totalBatches Int @default(0) + currentBatch Int @default(0) + processedCount Int @default(0) + suggestionsCount Int @default(0) + suggestionsJson Json? @db.JsonB // Stores the AI-generated suggestions + errorMessage String? @db.Text + startedAt DateTime? + completedAt DateTime? + fallbackUsed Boolean @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + + @@index([roundId]) + @@index([status]) +} + +enum AssignmentJobStatus { + PENDING + RUNNING + COMPLETED + FAILED +} + +// ============================================================================= +// AI RANKING MODELS +// ============================================================================= + +enum RankingTriggerType { + MANUAL // Admin clicked "Run ranking" + AUTO // Auto-triggered by assignment completion + RETROACTIVE // Retroactive scan on deployment + QUICK // Quick-rank mode (no preview) +} + +enum RankingMode { + 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 +} + +enum RankingSnapshotStatus { + PENDING + RUNNING + COMPLETED + FAILED +} + +// Captures a point-in-time AI ranking run for a round +model RankingSnapshot { + id String @id @default(cuid()) + + roundId String + + // Trigger metadata + triggeredById String? // null = auto-triggered + triggerType RankingTriggerType @default(MANUAL) + + // Criteria used + criteriaText String @db.Text + parsedRulesJson Json @db.JsonB + + // Results per category (either can be null/empty if no projects in that category) + startupRankingJson Json? @db.JsonB + conceptRankingJson Json? @db.JsonB + + // Evaluation data freeze (raw scores at time of ranking) + evaluationDataJson Json? @db.JsonB + + // Mode and status + mode RankingMode @default(PREVIEW) + status RankingSnapshotStatus @default(COMPLETED) + + // Post-drag-and-drop reorders (Phase 2 will populate this) + reordersJson Json? @db.JsonB + + // AI metadata + model String? + tokensUsed Int @default(0) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + round Round @relation("RoundRankingSnapshots", fields: [roundId], references: [id], onDelete: Cascade) + triggeredBy User? @relation("TriggeredRankingSnapshots", fields: [triggeredById], references: [id], onDelete: SetNull) + + @@index([roundId]) + @@index([triggeredById]) + @@index([createdAt]) + @@index([roundId, createdAt]) +} + +// Tracks progress of long-running AI tagging jobs +model TaggingJob { + id String @id @default(cuid()) + programId String? // If tagging entire program + roundId String? // If tagging single round + status TaggingJobStatus @default(PENDING) + totalProjects Int @default(0) + processedCount Int @default(0) + taggedCount Int @default(0) + skippedCount Int @default(0) + failedCount Int @default(0) + errorMessage String? @db.Text + errorsJson Json? @db.JsonB // Array of error messages + startedAt DateTime? + completedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations (optional - can tag by program) + program Program? @relation(fields: [programId], references: [id], onDelete: Cascade) + + @@index([programId]) + @@index([status]) +} + +enum TaggingJobStatus { + PENDING + RUNNING + COMPLETED + FAILED +} + +// ============================================================================= +// SPECIAL AWARDS SYSTEM +// ============================================================================= + +enum AwardScoringMode { + PICK_WINNER + RANKED + SCORED +} + +enum AwardStatus { + DRAFT + NOMINATIONS_OPEN + VOTING_OPEN + CLOSED + ARCHIVED +} + +enum EligibilityMethod { + AUTO + MANUAL +} + +model SpecialAward { + id String @id @default(cuid()) + programId String + name String + description String? @db.Text + status AwardStatus @default(DRAFT) + + // Criteria + criteriaText String? @db.Text // Plain-language criteria for AI + autoTagRulesJson Json? @db.JsonB // Deterministic eligibility rules + useAiEligibility Boolean @default(true) // Whether AI evaluates eligibility + + // Scoring + scoringMode AwardScoringMode @default(PICK_WINNER) + maxRankedPicks Int? // For RANKED mode + + // Voting window + votingStartAt DateTime? + votingEndAt DateTime? + + // Evaluation form (for SCORED mode) + evaluationFormId String? + + // Winner + winnerProjectId String? + winnerOverridden Boolean @default(false) + winnerOverriddenBy String? // FK to User who overrode the winner + + sortOrder Int @default(0) + + // ── Competition/Round architecture fields ── + competitionId String? + evaluationRoundId String? + juryGroupId String? + eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN) + decisionMode String? // "JURY_VOTE" | "ADMIN_DECISION" + shortlistSize Int @default(10) + + // Eligibility job tracking + eligibilityJobStatus String? // PENDING, PROCESSING, COMPLETED, FAILED + eligibilityJobTotal Int? // total projects to process + eligibilityJobDone Int? // completed so far + eligibilityJobError String? @db.Text // error message if failed + eligibilityJobStarted DateTime? // when job started + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + program Program @relation(fields: [programId], references: [id], onDelete: Cascade) + winnerProject Project? @relation("AwardWinner", fields: [winnerProjectId], references: [id], onDelete: SetNull) + overriddenByUser User? @relation("AwardOverriddenBy", fields: [winnerOverriddenBy], references: [id], onDelete: SetNull) + eligibilities AwardEligibility[] + jurors AwardJuror[] + 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") + + @@index([programId]) + @@index([status]) + @@index([sortOrder]) + @@index([competitionId]) + @@index([evaluationRoundId]) +} + +model AwardEligibility { + id String @id @default(cuid()) + awardId String + projectId String + method EligibilityMethod @default(AUTO) + eligible Boolean @default(false) + aiReasoningJson Json? @db.JsonB + qualityScore Float? + shortlisted Boolean @default(false) + + // Admin override + overriddenBy String? + overriddenAt DateTime? + + // Shortlist confirmation + confirmedAt DateTime? + confirmedBy String? + + // Pool notification tracking + notifiedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + overriddenByUser User? @relation("AwardEligibilityOverriddenBy", fields: [overriddenBy], references: [id], onDelete: SetNull) + confirmer User? @relation("AwardEligibilityConfirmer", fields: [confirmedBy], references: [id], onDelete: SetNull) + + @@unique([awardId, projectId]) + @@index([awardId]) + @@index([projectId]) + @@index([eligible]) + @@index([awardId, eligible]) +} + +model AwardJuror { + id String @id @default(cuid()) + awardId String + userId String + isChair Boolean @default(false) + + createdAt DateTime @default(now()) + + // Relations + award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([awardId, userId]) + @@index([awardId]) + @@index([userId]) +} + +model AwardVote { + 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 + award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([awardId, userId, projectId]) + @@index([awardId]) + @@index([userId]) + @@index([projectId]) + @@index([awardId, userId]) +} + +// ============================================================================= +// REMINDER LOG (Evaluation Deadline Reminders) +// ============================================================================= + +model ReminderLog { + id String @id @default(cuid()) + roundId String + userId String + type String // "3_DAYS", "24H", "1H" + sentAt DateTime @default(now()) + + // Relations + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([roundId, userId, type]) + @@index([roundId]) +} + +// ============================================================================= +// CONFLICT OF INTEREST +// ============================================================================= + +model ConflictOfInterest { + id String @id @default(cuid()) + assignmentId String @unique + userId String + projectId String + hasConflict Boolean @default(false) + conflictType String? // "financial", "personal", "organizational", "other" + description String? @db.Text + declaredAt DateTime @default(now()) + + // Admin review + reviewedById String? + reviewedAt DateTime? + reviewAction String? // "cleared", "reassigned", "noted" + + // Relations + assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id]) + reviewedBy User? @relation("COIReviewedBy", fields: [reviewedById], references: [id]) + + @@index([userId]) + @@index([hasConflict]) + @@index([projectId]) + @@index([userId, hasConflict]) +} + +// ============================================================================= +// AI EVALUATION SUMMARY +// ============================================================================= + +model EvaluationSummary { + id String @id @default(cuid()) + projectId String + roundId String + summaryJson Json @db.JsonB + generatedAt DateTime @default(now()) + generatedById String + model String + tokensUsed Int + + // Relations + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + generatedBy User @relation("EvaluationSummaryGeneratedBy", fields: [generatedById], references: [id]) + + @@unique([projectId, roundId]) + @@index([roundId]) +} + +// ============================================================================= +// PROJECT STATUS HISTORY +// ============================================================================= + +model ProjectStatusHistory { + id String @id @default(cuid()) + projectId String + status ProjectStatus + changedAt DateTime @default(now()) + changedBy String? + + // Relations + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@index([projectId, changedAt]) +} + +// ============================================================================= +// MENTOR MESSAGES +// ============================================================================= + +model MentorMessage { + id String @id @default(cuid()) + projectId String + senderId String + message String @db.Text + isRead Boolean @default(false) + createdAt DateTime @default(now()) + + // ── Competition/Round architecture fields ── + workspaceId String? // FK to MentorAssignment (used as workspace) + senderRole MentorMessageRole? + + // Relations + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + sender User @relation("MentorMessageSender", fields: [senderId], references: [id]) + workspace MentorAssignment? @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + @@index([projectId, createdAt]) + @@index([workspaceId]) +} + +// RoundTemplate model RETIRED in Phase 6 — replaced by WizardTemplate for pipelines. + +// ============================================================================= +// MENTOR NOTES & MILESTONES +// ============================================================================= + +model MentorNote { + id String @id @default(cuid()) + mentorAssignmentId String + authorId String + content String @db.Text + isVisibleToAdmin Boolean @default(true) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade) + author User @relation("MentorNoteAuthor", fields: [authorId], references: [id]) + + @@index([mentorAssignmentId]) + @@index([authorId]) +} + +model MentorMilestone { + id String @id @default(cuid()) + programId String + name String + description String? @db.Text + isRequired Boolean @default(false) + deadlineOffsetDays Int? + sortOrder Int @default(0) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + program Program @relation(fields: [programId], references: [id], onDelete: Cascade) + completions MentorMilestoneCompletion[] + + @@index([programId]) + @@index([sortOrder]) +} + +model MentorMilestoneCompletion { + id String @id @default(cuid()) + milestoneId String + mentorAssignmentId String + completedById String + completedAt DateTime @default(now()) + + // Relations + milestone MentorMilestone @relation(fields: [milestoneId], references: [id], onDelete: Cascade) + mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade) + completedBy User @relation("MilestoneCompletedBy", fields: [completedById], references: [id]) + + @@unique([milestoneId, mentorAssignmentId]) + @@index([mentorAssignmentId]) + @@index([completedById]) +} + +// ============================================================================= +// EVALUATION DISCUSSIONS +// ============================================================================= + +model EvaluationDiscussion { + id String @id @default(cuid()) + projectId String + roundId String + status String @default("open") // 'open' | 'closed' + createdAt DateTime @default(now()) + closedAt DateTime? + closedById String? + + // Relations + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + closedBy User? @relation("DiscussionClosedBy", fields: [closedById], references: [id], onDelete: SetNull) + comments DiscussionComment[] + + @@unique([projectId, roundId]) + @@index([roundId]) + @@index([closedById]) +} + +model DiscussionComment { + id String @id @default(cuid()) + discussionId String + userId String + content String @db.Text + + createdAt DateTime @default(now()) + + // Relations + discussion EvaluationDiscussion @relation(fields: [discussionId], references: [id], onDelete: Cascade) + user User @relation("DiscussionCommentAuthor", fields: [userId], references: [id]) + + @@index([discussionId]) + @@index([userId]) +} + +// ============================================================================= +// MESSAGING SYSTEM +// ============================================================================= + +model Message { + id String @id @default(cuid()) + senderId String + recipientType String // 'USER', 'ROLE', 'ROUND_JURY', 'PROGRAM_TEAM', 'ALL' + recipientFilter Json? @db.JsonB + roundId String? + templateId String? + subject String + body String @db.Text + deliveryChannels String[] + + scheduledAt DateTime? + sentAt DateTime? + metadata Json? @db.JsonB + + createdAt DateTime @default(now()) + + // Relations + sender User @relation("MessageSender", fields: [senderId], references: [id]) + round Round? @relation(fields: [roundId], references: [id], onDelete: SetNull) + template MessageTemplate? @relation(fields: [templateId], references: [id], onDelete: SetNull) + recipients MessageRecipient[] + + @@index([senderId]) + @@index([roundId]) + @@index([sentAt]) +} + +model MessageRecipient { + id String @id @default(cuid()) + messageId String + userId String + channel String // 'EMAIL', 'IN_APP', etc. + isRead Boolean @default(false) + readAt DateTime? + deliveredAt DateTime? + + // Relations + message Message @relation(fields: [messageId], references: [id], onDelete: Cascade) + user User @relation("MessageRecipient", fields: [userId], references: [id], onDelete: Cascade) + + @@unique([messageId, userId, channel]) + @@index([userId]) +} + +model MessageTemplate { + id String @id @default(cuid()) + name String + category String // 'SYSTEM', 'EVALUATION', 'ASSIGNMENT' + subject String + body String @db.Text + variables Json? @db.JsonB + isActive Boolean @default(true) + createdBy String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + creator User @relation("MessageTemplateCreator", fields: [createdBy], references: [id]) + messages Message[] + + @@index([category]) + @@index([isActive]) +} + +// ============================================================================= +// WEBHOOKS +// ============================================================================= + +model Webhook { + id String @id @default(cuid()) + name String + url String + secret String + events String[] + headers Json? @db.JsonB + maxRetries Int @default(3) + isActive Boolean @default(true) + createdById String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + createdBy User @relation("WebhookCreator", fields: [createdById], references: [id]) + deliveries WebhookDelivery[] + + @@index([isActive]) + @@index([createdById]) +} + +model WebhookDelivery { + id String @id @default(cuid()) + webhookId String + event String + payload Json @db.JsonB + status String @default("PENDING") // 'PENDING', 'DELIVERED', 'FAILED' + responseStatus Int? + responseBody String? @db.Text + attempts Int @default(0) + lastAttemptAt DateTime? + + createdAt DateTime @default(now()) + + // Relations + webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade) + + @@index([webhookId]) + @@index([status]) + @@index([event]) +} + +// ============================================================================= +// DIGEST LOGS +// ============================================================================= + +model DigestLog { + id String @id @default(cuid()) + userId String + digestType String // 'daily' | 'weekly' + contentJson Json @db.JsonB + sentAt DateTime @default(now()) + + // Relations + user User @relation("DigestLog", fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([sentAt]) +} + +// ============================================================================= +// COHORT & LIVE PROGRESS (formerly part of Stage engine, now refit to Round) +// ============================================================================= + +model Cohort { + id String @id @default(cuid()) + roundId String + name String + votingMode String @default("simple") // simple, criteria, ranked + isOpen Boolean @default(false) + windowOpenAt DateTime? + windowCloseAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + projects CohortProject[] + + @@index([roundId]) + @@index([isOpen]) +} + +model CohortProject { + id String @id @default(cuid()) + cohortId String + projectId String + sortOrder Int @default(0) + + createdAt DateTime @default(now()) + + // Relations + cohort Cohort @relation(fields: [cohortId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([cohortId, projectId]) + @@index([cohortId]) + @@index([projectId]) + @@index([sortOrder]) +} + +model LiveProgressCursor { + id String @id @default(cuid()) + roundId String @unique + sessionId String @unique @default(cuid()) + activeProjectId String? + activeOrderIndex Int @default(0) + isPaused Boolean @default(false) + + // Per-project ceremony phase + server-stamped timer (grand finale) + projectPhase LivePhase @default(ON_DECK) + phaseStartedAt DateTime? + phaseDurationSeconds Int? + phasePausedAt DateTime? + phasePausedAccumMs Int @default(0) + timingLogJson Json? @db.JsonB // [{projectId, phase, startedAt, endedAt, configuredSeconds, overranSeconds}] + overrideSlide String? // big-screen override: 'welcome' | 'break' | 'deliberation' | 'thanks' + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + + @@index([sessionId]) +} + +model DecisionAuditLog { + id String @id @default(cuid()) + eventType String // stage.transitioned, routing.executed, filtering.completed, etc. + entityType String + entityId String + actorId String? + detailsJson Json? @db.JsonB + snapshotJson Json? @db.JsonB // State at time of decision + + createdAt DateTime @default(now()) + + @@index([eventType]) + @@index([entityType, entityId]) + @@index([actorId]) + @@index([createdAt]) +} + +// ============================================================================= +// COMPETITION / ROUND ENGINE MODELS (NEW — coexists with Pipeline/Track/Stage) +// ============================================================================= + +model Competition { + id String @id @default(cuid()) + programId String + name String + slug String @unique + status CompetitionStatus @default(DRAFT) + + // Competition-wide settings + categoryMode String @default("SHARED") + startupFinalistCount Int @default(3) + conceptFinalistCount Int @default(3) + + // Notification preferences + notifyOnRoundAdvance Boolean @default(true) + notifyOnDeadlineApproach Boolean @default(true) + deadlineReminderDays Int[] @default([7, 3, 1]) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + program Program @relation(fields: [programId], references: [id], onDelete: Cascade) + rounds Round[] + juryGroups JuryGroup[] + submissionWindows SubmissionWindow[] + specialAwards SpecialAward[] + deliberationSessions DeliberationSession[] + resultLocks ResultLock[] + + @@index([programId]) + @@index([status]) +} + +model Round { + id String @id @default(cuid()) + competitionId String + name String + slug String + roundType RoundType + status RoundStatus @default(ROUND_DRAFT) + sortOrder Int @default(0) + + // Time windows + windowOpenAt DateTime? + windowCloseAt DateTime? + + // Round-type-specific configuration (validated by Zod per RoundType) + configJson Json? @db.JsonB + + // Semantic analytics tag + purposeKey String? + + // Links to other entities + juryGroupId String? + submissionWindowId String? + specialAwardId String? + + // Finalization + gracePeriodEndsAt DateTime? + finalizedAt DateTime? + finalizedBy String? + + createdAt DateTime @default(now()) + 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) + projectRoundStates ProjectRoundState[] + visibleSubmissionWindows RoundSubmissionVisibility[] + assignmentIntents AssignmentIntent[] + deliberationSessions DeliberationSession[] + resultLocks ResultLock[] + submissionPromotions SubmissionPromotionEvent[] + specialAwards SpecialAward[] + + // Reverse relations from refitted models + evaluationForms EvaluationForm[] + fileRequirements FileRequirement[] + assignments Assignment[] + gracePeriods GracePeriod[] + liveVotingSession LiveVotingSession? + filteringRules FilteringRule[] + filteringResults FilteringResult[] + filteringJobs FilteringJob[] + assignmentJobs AssignmentJob[] + rankingSnapshots RankingSnapshot[] @relation("RoundRankingSnapshots") + reminderLogs ReminderLog[] + evaluationSummaries EvaluationSummary[] + evaluationDiscussions EvaluationDiscussion[] + messages Message[] + notificationLogs NotificationLog[] + cohorts Cohort[] + liveCursor LiveProgressCursor? + liveNotes LiveNote[] + + @@unique([competitionId, slug]) + @@unique([competitionId, sortOrder]) + @@index([competitionId]) + @@index([roundType]) + @@index([status]) + @@index([specialAwardId]) +} + +model ProjectRoundState { + id String @id @default(cuid()) + projectId String + roundId String + state ProjectRoundStateValue @default(PENDING) + proposedOutcome ProjectRoundStateValue? + enteredAt DateTime @default(now()) + exitedAt DateTime? + metadataJson Json? @db.JsonB + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + + @@unique([projectId, roundId]) + @@index([projectId]) + @@index([roundId]) + @@index([state]) + @@index([roundId, state]) +} + +// ============================================================================= +// JURY GROUP MODELS (NEW) +// ============================================================================= + +model JuryGroup { + id String @id @default(cuid()) + competitionId String + name String + slug String + description String? @db.Text + sortOrder Int @default(0) + + // Default assignment configuration + defaultMaxAssignments Int @default(20) + defaultCapMode CapMode @default(SOFT) + softCapBuffer Int @default(2) + + // Default category quotas + categoryQuotasEnabled Boolean @default(false) + defaultCategoryQuotas Json? @db.JsonB + + // Onboarding self-service + allowJurorCapAdjustment Boolean @default(false) + allowJurorRatioAdjustment Boolean @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade) + members JuryGroupMember[] + rounds Round[] + assignments Assignment[] + awards SpecialAward[] + + @@unique([competitionId, slug]) + @@index([competitionId]) +} + +model JuryGroupMember { + id String @id @default(cuid()) + juryGroupId String + userId String + role JuryGroupMemberRole @default(MEMBER) + joinedAt DateTime @default(now()) + + // Per-juror overrides (null = use group defaults) + maxAssignmentsOverride Int? + capModeOverride CapMode? + categoryQuotasOverride Json? @db.JsonB + + // Juror preferences (admin-set) + preferredStartupRatio Float? + availabilityNotes String? @db.Text + + // Self-service overrides (set by juror during onboarding, Layer 4b) + selfServiceCap Int? + selfServiceRatio Float? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + juryGroup JuryGroup @relation(fields: [juryGroupId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + assignmentIntents AssignmentIntent[] + deliberationVotes DeliberationVote[] + deliberationParticipations DeliberationParticipant[] + + @@unique([juryGroupId, userId]) + @@index([juryGroupId]) + @@index([userId]) +} + +// ============================================================================= +// SUBMISSION WINDOW MODELS (NEW) +// ============================================================================= + +model SubmissionWindow { + id String @id @default(cuid()) + competitionId String + name String + slug String + roundNumber Int + sortOrder Int @default(0) + + // Window timing + windowOpenAt DateTime? + windowCloseAt DateTime? + + // Deadline behavior + deadlinePolicy DeadlinePolicy @default(FLAG) + graceHours Int? + + // Locking behavior + lockOnClose Boolean @default(true) + isLocked Boolean @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade) + fileRequirements SubmissionFileRequirement[] + projectFiles ProjectFile[] + rounds Round[] + visibility RoundSubmissionVisibility[] + + @@unique([competitionId, slug]) + @@unique([competitionId, roundNumber]) + @@index([competitionId]) +} + +model SubmissionFileRequirement { + id String @id @default(cuid()) + submissionWindowId String + label String + slug String + description String? @db.Text + mimeTypes String[] + maxSizeMb Int? + required Boolean @default(true) + sortOrder Int @default(0) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + submissionWindow SubmissionWindow @relation(fields: [submissionWindowId], references: [id], onDelete: Cascade) + files ProjectFile[] + + @@unique([submissionWindowId, slug]) + @@index([submissionWindowId]) +} + +model RoundSubmissionVisibility { + id String @id @default(cuid()) + roundId String + submissionWindowId String + canView Boolean @default(true) + displayLabel String? + + // Relations + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + submissionWindow SubmissionWindow @relation(fields: [submissionWindowId], references: [id], onDelete: Cascade) + + @@unique([roundId, submissionWindowId]) + @@index([roundId]) +} + +// ============================================================================= +// ASSIGNMENT GOVERNANCE MODELS (NEW) +// ============================================================================= + +model AssignmentIntent { + id String @id @default(cuid()) + juryGroupMemberId String + roundId String + projectId String + source AssignmentIntentSource + status AssignmentIntentStatus @default(INTENT_PENDING) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + juryGroupMember JuryGroupMember @relation(fields: [juryGroupMemberId], references: [id], onDelete: Cascade) + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([juryGroupMemberId, roundId, projectId]) + @@index([roundId]) + @@index([projectId]) + @@index([status]) +} + +// ============================================================================= +// MENTORING WORKSPACE MODELS (NEW) +// ============================================================================= + +model MentorFile { + 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 + mimeType String + size Int + bucket String + objectKey String + description String? @db.Text + + // Promotion to official submission + isPromoted Boolean @default(false) + promotedToFileId String? @unique + promotedAt DateTime? + promotedByUserId String? + + createdAt DateTime @default(now()) + + // Relations + 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]) +} + +model MentorFileComment { + id String @id @default(cuid()) + mentorFileId String + authorId String + content String @db.Text + + // Threading support + parentCommentId String? + + createdAt DateTime @default(now()) + 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) + replies MentorFileComment[] @relation("CommentThread") + + @@index([mentorFileId]) + @@index([authorId]) + @@index([parentCommentId]) +} + +model SubmissionPromotionEvent { + id String @id @default(cuid()) + projectId String + roundId String + slotKey String + sourceType SubmissionPromotionSource + sourceFileId String? + promotedById String + createdAt DateTime @default(now()) + + // Relations + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + sourceFile MentorFile? @relation(fields: [sourceFileId], references: [id], onDelete: SetNull) + promotedBy User @relation("SubmissionPromoter", fields: [promotedById], references: [id]) + + @@index([projectId]) + @@index([roundId]) + @@index([sourceFileId]) +} + +// ============================================================================= +// DELIBERATION MODELS (NEW) +// ============================================================================= + +model DeliberationSession { + id String @id @default(cuid()) + competitionId String + roundId String + category CompetitionCategory + mode DeliberationMode + showCollectiveRankings Boolean @default(false) + showPriorJuryData Boolean @default(false) + status DeliberationStatus + tieBreakMethod TieBreakMethod + adminOverrideResult Json? @db.JsonB + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade) + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + votes DeliberationVote[] + results DeliberationResult[] + participants DeliberationParticipant[] + + @@index([competitionId]) + @@index([roundId]) + @@index([status]) +} + +model DeliberationVote { + id String @id @default(cuid()) + sessionId String + juryMemberId String + projectId String + rank Int? + isWinnerPick Boolean @default(false) + runoffRound Int @default(0) + createdAt DateTime @default(now()) + + // Relations + session DeliberationSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + juryMember JuryGroupMember @relation(fields: [juryMemberId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([sessionId, juryMemberId, projectId, runoffRound]) + @@index([sessionId]) + @@index([juryMemberId]) + @@index([projectId]) +} + +model DeliberationResult { + id String @id @default(cuid()) + sessionId String + projectId String + finalRank Int + voteCount Int @default(0) + isAdminOverridden Boolean @default(false) + overrideReason String? @db.Text + + // Relations + session DeliberationSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([sessionId, projectId]) + @@index([sessionId]) + @@index([projectId]) +} + +model DeliberationParticipant { + id String @id @default(cuid()) + sessionId String + userId String + status DeliberationParticipantStatus + replacedById String? + + // Relations + session DeliberationSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + user JuryGroupMember @relation(fields: [userId], references: [id], onDelete: Cascade) + replacedBy User? @relation("DeliberationReplacement", fields: [replacedById], references: [id]) + + @@unique([sessionId, userId]) + @@index([sessionId]) + @@index([userId]) +} + +// ============================================================================= +// RESULT LOCKING MODELS (NEW) +// ============================================================================= + +model ResultLock { + id String @id @default(cuid()) + competitionId String + roundId String + category CompetitionCategory + lockedById String + resultSnapshot Json @db.JsonB + lockedAt DateTime @default(now()) + + // Relations + competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade) + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + lockedBy User @relation("ResultLockCreator", fields: [lockedById], references: [id]) + unlockEvents ResultUnlockEvent[] + + @@index([competitionId]) + @@index([roundId]) + @@index([category]) +} + +model ResultUnlockEvent { + id String @id @default(cuid()) + resultLockId String + unlockedById String + reason String @db.Text + unlockedAt DateTime @default(now()) + + // Relations + resultLock ResultLock @relation(fields: [resultLockId], references: [id], onDelete: Cascade) + unlockedBy User @relation("ResultUnlocker", fields: [unlockedById], references: [id]) + + @@index([resultLockId]) + @@index([unlockedById]) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Grand-finale logistics (PR 1: finalist confirmation flow) +// ───────────────────────────────────────────────────────────────────────────── + +enum WaitlistEntryStatus { + WAITING // available for promotion + PROMOTED // moved into a finalist slot + USED // promoted and confirmation flow completed (declined or accepted) +} + +enum FinalistConfirmationStatus { + PENDING // sent, awaiting team response + CONFIRMED // team accepted, attendees selected + DECLINED // team explicitly declined + EXPIRED // deadline passed without response + SUPERSEDED // admin manually overrode (e.g. unconfirmed to allow quota decrease) +} + +model FinalistSlotQuota { + id String @id @default(cuid()) + programId String + category CompetitionCategory + quota Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + program Program @relation(fields: [programId], references: [id], onDelete: Cascade) + + @@unique([programId, category]) + @@index([programId]) +} + +model WaitlistEntry { + id String @id @default(cuid()) + programId String + projectId String @unique + category CompetitionCategory + rank Int + status WaitlistEntryStatus @default(WAITING) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + program Program @relation(fields: [programId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([programId, category, rank]) + @@index([programId, category, status]) +} + +model FinalistConfirmation { + id String @id @default(cuid()) + projectId String @unique + category CompetitionCategory + status FinalistConfirmationStatus @default(PENDING) + deadline DateTime + token String @unique + confirmedAt DateTime? + declinedAt DateTime? + declineReason String? // optional free-text on decline + expiredAt DateTime? + reminderSentAt DateTime? // set when the pre-deadline reminder is sent (cron) + finalDocsReminderSentAt DateTime? // set when the grand-final document-upload reminder is sent (cron) + promotedFromWaitlistEntryId String? @unique // null for original finalists + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + attendingMembers AttendingMember[] + + @@index([status, deadline]) // for cron scan + @@index([category, status]) +} + +model AttendingMember { + id String @id @default(cuid()) + confirmationId String + userId String // must be a TeamMember of the same project (validated at write time) + needsVisa Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + confirmation FinalistConfirmation @relation(fields: [confirmationId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + flightDetail FlightDetail? + visaApplication VisaApplication? + lunchPick MemberLunchPick? + hotelStay HotelStay? + + @@unique([confirmationId, userId]) + @@index([userId]) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Grand-finale logistics (PR 2: hotels + flight tracking) +// ───────────────────────────────────────────────────────────────────────────── + +enum FlightDetailStatus { + PENDING // team submitted details, admin not yet reviewed + CONFIRMED // admin verified booking +} + +model Hotel { + id String @id @default(cuid()) + programId String // many hotels per edition + name String + address String? @db.Text + link String? // external URL to hotel page / booking confirmation + notes String? @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + program Program @relation(fields: [programId], references: [id], onDelete: Cascade) + stays HotelStay[] + + @@index([programId]) +} + +/// Per-attendee hotel/room assignment (1:1 with AttendingMember, mirrors FlightDetail). +model HotelStay { + id String @id @default(cuid()) + attendingMemberId String @unique + hotelId String + roomNumber String? + checkInAt DateTime? + checkOutAt DateTime? + notes String? @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade) + hotel Hotel @relation(fields: [hotelId], references: [id], onDelete: Restrict) + + @@index([hotelId]) +} + +model FlightDetail { + id String @id @default(cuid()) + attendingMemberId String @unique // 1:1 + arrivalAt DateTime? + arrivalFlightNumber String? + arrivalAirport String? + departureAt DateTime? + departureFlightNumber String? + departureAirport String? + status FlightDetailStatus @default(PENDING) + adminNotes String? @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade) + + @@index([status]) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Grand-finale visa tracking (PR 4) +// Process metadata only — no document storage. Passport scans / invitation +// letters / decision documents are exchanged over email; this model just +// records what stage the application is at, key dates, and free-text notes. +// ───────────────────────────────────────────────────────────────────────────── + +enum VisaStatus { + NOT_NEEDED + REQUESTED + INVITATION_SENT + APPOINTMENT_BOOKED + GRANTED + DENIED +} + +model VisaApplication { + id String @id @default(cuid()) + attendingMemberId String @unique // 1:1 + status VisaStatus @default(REQUESTED) + nationality String? // self-declared, optional + invitationSentAt DateTime? + appointmentAt DateTime? + decisionAt DateTime? // GRANTED or DENIED date + notes String? @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade) + + @@index([status]) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Grand-finale lunch event (PR 6) +// Single configurable lunch event per edition. Each attending member has a +// 1:1 MemberLunchPick (auto-created via lunch-pick-sync). External attendees +// can be standalone or attached to a finalist project. Allergens use the +// EU 14 regulated list; dishes carry dietary tags. +// ───────────────────────────────────────────────────────────────────────────── + +enum DietaryTag { + VEGETARIAN + VEGAN + GLUTEN_FREE + PESCATARIAN +} + +enum Allergen { + GLUTEN + CRUSTACEANS + EGGS + FISH + PEANUTS + SOYBEANS + MILK + TREE_NUTS + CELERY + MUSTARD + SESAME + SULPHITES + LUPIN + MOLLUSCS +} + +model LunchEvent { + id String @id @default(cuid()) + programId String @unique + enabled Boolean @default(false) + eventAt DateTime? + endAt DateTime? + venue String? + notes String? @db.Text + changeCutoffHours Int @default(48) + reminderHoursBeforeDeadline Int? + cronEnabled Boolean @default(true) + extraRecipients String[] @default([]) + reminderSentAt DateTime? + recapSentAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + program Program @relation(fields: [programId], references: [id], onDelete: Cascade) + dishes Dish[] + externalAttendees ExternalAttendee[] +} + +model Dish { + id String @id @default(cuid()) + lunchEventId String + name String + sortOrder Int @default(0) + dietaryTags DietaryTag[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + lunchEvent LunchEvent @relation(fields: [lunchEventId], references: [id], onDelete: Cascade) + memberPicks MemberLunchPick[] + externals ExternalAttendee[] + + @@index([lunchEventId]) +} + +model MemberLunchPick { + id String @id @default(cuid()) + attendingMemberId String @unique + dishId String? + allergens Allergen[] @default([]) + allergenOther String? + pickedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade) + dish Dish? @relation(fields: [dishId], references: [id], onDelete: SetNull) + + @@index([dishId]) +} + +model ExternalAttendee { + id String @id @default(cuid()) + lunchEventId String + projectId String? + name String + email String? + roleNote String? + dishId String? + allergens Allergen[] @default([]) + allergenOther String? + inviteSentAt DateTime? // when the dish-selection email was last sent (null = never invited) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + lunchEvent LunchEvent @relation(fields: [lunchEventId], references: [id], onDelete: Cascade) + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) + dish Dish? @relation(fields: [dishId], references: [id], onDelete: SetNull) + + @@index([lunchEventId]) + @@index([projectId]) +}