diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 665d9e6..6e3a713 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -110,6 +110,10 @@ enum SettingCategory { SECURITY DEFAULTS WHATSAPP + AUDIT_CONFIG + LOCALIZATION + DIGEST + ANALYTICS } enum NotificationChannel { @@ -222,6 +226,11 @@ model User { inviteToken String? @unique inviteTokenExpiresAt DateTime? + // Digest & availability preferences + digestFrequency String? // 'none' | 'daily' | 'weekly' + preferredWorkload Int? + availabilityJson Json? @db.JsonB // { startDate?: string, endDate?: string } + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt lastLoginAt DateTime? @@ -272,6 +281,30 @@ model User { // Wizard templates wizardTemplates WizardTemplate[] @relation("WizardTemplateCreatedBy") + // Round templates + roundTemplates RoundTemplate[] @relation("RoundTemplateCreatedBy") + + // 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[] @@ -344,6 +377,8 @@ model Program { specialAwards SpecialAward[] taggingJobs TaggingJob[] wizardTemplates WizardTemplate[] + roundTemplates RoundTemplate[] + mentorMilestones MentorMilestone[] @@unique([name, year]) @@index([status]) @@ -415,6 +450,8 @@ model Round { taggingJobs TaggingJob[] reminderLogs ReminderLog[] projectFiles ProjectFile[] + evaluationDiscussions EvaluationDiscussion[] + messages Message[] @@index([programId]) @@index([status]) @@ -499,6 +536,11 @@ model Project { 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. @@ -523,6 +565,7 @@ model Project { statusHistory ProjectStatusHistory[] mentorMessages MentorMessage[] evaluationSummaries EvaluationSummary[] + evaluationDiscussions EvaluationDiscussion[] @@index([programId]) @@index([roundId]) @@ -553,11 +596,17 @@ model ProjectFile { isLate Boolean @default(false) // Uploaded after round deadline + // Versioning + version Int @default(1) + replacedById String? // FK to the newer file that replaced this one + createdAt DateTime @default(now()) // Relations - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - round Round? @relation(fields: [roundId], references: [id]) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + round Round? @relation(fields: [roundId], references: [id]) + replacedBy ProjectFile? @relation("FileVersions", fields: [replacedById], references: [id], onDelete: SetNull) + replacements ProjectFile[] @relation("FileVersions") @@unique([bucket, objectKey]) @@index([projectId]) @@ -705,6 +754,7 @@ model AuditLog { // Request info ipAddress String? userAgent String? + sessionId String? timestamp DateTime @default(now()) @@ -716,6 +766,7 @@ model AuditLog { @@index([entityType, entityId]) @@index([timestamp]) @@index([entityType, entityId, timestamp]) + @@index([sessionId]) } // ============================================================================= @@ -969,32 +1020,39 @@ model ProjectTag { // ============================================================================= 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 + 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 - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + // Audience & presentation settings + allowAudienceVotes Boolean @default(false) + audienceVoteWeight Float? // 0.0 to 1.0 + tieBreakerMethod String? // 'admin_decides' | 'highest_individual' | 'revote' + presentationSettingsJson Json? @db.JsonB + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations - round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) - votes LiveVote[] + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + votes LiveVote[] @@index([status]) } model LiveVote { - id String @id @default(cuid()) - sessionId String - projectId String - userId String - score Int // 1-10 - votedAt DateTime @default(now()) + id String @id @default(cuid()) + sessionId String + projectId String + userId String + score Int // 1-10 + isAudienceVote Boolean @default(false) + votedAt DateTime @default(now()) // Relations session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) @@ -1047,9 +1105,15 @@ model MentorAssignment { expertiseMatchScore Float? aiReasoning String? @db.Text + // Tracking + completionStatus String @default("in_progress") // 'in_progress' | 'completed' | 'paused' + lastViewedAt DateTime? + // Relations - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - mentor User @relation("MentorAssignments", fields: [mentorId], references: [id]) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + mentor User @relation("MentorAssignments", fields: [mentorId], references: [id]) + notes MentorNote[] + milestoneCompletions MentorMilestoneCompletion[] @@index([mentorId]) @@index([method]) @@ -1454,3 +1518,269 @@ model MentorMessage { @@index([projectId, createdAt]) } + +// ============================================================================= +// ROUND TEMPLATES +// ============================================================================= + +model RoundTemplate { + id String @id @default(cuid()) + name String + description String? + programId String? + roundType RoundType @default(EVALUATION) + criteriaJson Json @db.JsonB + settingsJson Json? @db.JsonB + assignmentConfig Json? @db.JsonB + createdBy String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + program Program? @relation(fields: [programId], references: [id], onDelete: Cascade) + creator User @relation("RoundTemplateCreatedBy", fields: [createdBy], references: [id]) + + @@index([programId]) + @@index([roundType]) +} + +// ============================================================================= +// 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 { + 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]) + + @@id([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' + closedAt DateTime? + closedById String? + + 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) + 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? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + sender User @relation("MessageSender", fields: [senderId], references: [id], onDelete: Cascade) + 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? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // 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 + createdById String + isActive Boolean @default(true) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + createdBy User @relation("MessageTemplateCreator", fields: [createdById], references: [id], onDelete: Cascade) + 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], onDelete: Cascade) + 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()) + updatedAt DateTime @updatedAt + + // 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()) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + user User @relation("DigestLog", fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, sentAt]) +} diff --git a/src/app/(admin)/admin/programs/[id]/apply-settings/page.tsx b/src/app/(admin)/admin/programs/[id]/apply-settings/page.tsx index 2343a59..f530433 100644 --- a/src/app/(admin)/admin/programs/[id]/apply-settings/page.tsx +++ b/src/app/(admin)/admin/programs/[id]/apply-settings/page.tsx @@ -674,7 +674,7 @@ export default function ApplySettingsPage() { toast.success('Loaded preset: MOPC Classic') return } - const template = templates?.find((t) => t.id === value) + const template = templates?.find((t: { id: string; name: string; config: unknown }) => t.id === value) if (template) { setConfig(template.config as WizardConfig) setIsDirty(true) @@ -692,7 +692,7 @@ export default function ApplySettingsPage() { {templates && templates.length > 0 && ( <> - {templates.map((t) => ( + {templates.map((t: { id: string; name: string }) => ( {t.name} diff --git a/src/app/(admin)/admin/programs/page.tsx b/src/app/(admin)/admin/programs/page.tsx index ece6f96..bef0168 100644 --- a/src/app/(admin)/admin/programs/page.tsx +++ b/src/app/(admin)/admin/programs/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from 'react' import Link from 'next/link' +import type { Route } from 'next' import { prisma } from '@/lib/prisma' export const dynamic = 'force-dynamic' @@ -148,7 +149,7 @@ async function ProgramsContent() { - + Apply Settings @@ -202,7 +203,7 @@ async function ProgramsContent() {