Fix build errors: add missing Prisma models/fields and resolve TypeScript type errors
Build and Push Docker Image / build (push) Successful in 11m24s Details

Schema: Add 11 new models (RoundTemplate, MentorNote, MentorMilestone,
MentorMilestoneCompletion, EvaluationDiscussion, DiscussionComment,
Message, MessageRecipient, MessageTemplate, Webhook, WebhookDelivery,
DigestLog) and missing fields on existing models (Project.isDraft,
ProjectFile.version, LiveVotingSession.allowAudienceVotes, User.digestFrequency,
AuditLog.sessionId, MentorAssignment.completionStatus, etc).
Add AUDIT_CONFIG/LOCALIZATION/DIGEST/ANALYTICS enum values.

Code: Fix implicit any types, route type casts, enum casts, null safety,
composite key handling, and relation field names across 11 source files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-08 14:04:02 +01:00
parent 24fdd2f6be
commit 04d0deced1
12 changed files with 383 additions and 51 deletions

View File

@ -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])
}

View File

@ -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() {
</SelectItem>
{templates && templates.length > 0 && (
<>
{templates.map((t) => (
{templates.map((t: { id: string; name: string }) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>

View File

@ -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() {
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/programs/${program.id}/apply-settings`}>
<Link href={`/admin/programs/${program.id}/apply-settings` as Route}>
<Wand2 className="mr-2 h-4 w-4" />
Apply Settings
</Link>
@ -202,7 +203,7 @@ async function ProgramsContent() {
</Link>
</Button>
<Button variant="outline" size="sm" className="flex-1" asChild>
<Link href={`/admin/programs/${program.id}/apply-settings`}>
<Link href={`/admin/programs/${program.id}/apply-settings` as Route}>
<Wand2 className="mr-2 h-4 w-4" />
Apply
</Link>

View File

@ -197,7 +197,7 @@ export default function RoundTemplatesPage() {
{/* Templates Grid */}
{templates && templates.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{templates.map((template) => {
{templates.map((template: typeof templates[number]) => {
const criteria = (template.criteriaJson as Array<unknown>) || []
const hasSettings = template.settingsJson && Object.keys(template.settingsJson as object).length > 0

View File

@ -80,7 +80,7 @@ function CreateRoundContent() {
const loadTemplate = (templateId: string) => {
if (!templateId || !templates) return
const template = templates.find((t) => t.id === templateId)
const template = templates.find((t: { id: string; name: string; roundType: string; settingsJson: unknown }) => t.id === templateId)
if (!template) return
// Apply template settings
@ -207,7 +207,7 @@ function CreateRoundContent() {
<SelectValue placeholder="Select a template..." />
</SelectTrigger>
<SelectContent>
{templates.map((t) => (
{templates.map((t: { id: string; name: string; description?: string | null }) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
{t.description ? ` - ${t.description}` : ''}

View File

@ -510,11 +510,11 @@ function MilestonesSection({
}
const completedCount = milestones.filter(
(m) => m.myCompletions.length > 0
(m: { myCompletions: unknown[] }) => m.myCompletions.length > 0
).length
const totalRequired = milestones.filter((m) => m.isRequired).length
const totalRequired = milestones.filter((m: { isRequired: boolean }) => m.isRequired).length
const requiredCompleted = milestones.filter(
(m) => m.isRequired && m.myCompletions.length > 0
(m: { isRequired: boolean; myCompletions: unknown[] }) => m.isRequired && m.myCompletions.length > 0
).length
const handleToggle = (milestoneId: string, isCompleted: boolean) => {
@ -545,7 +545,7 @@ function MilestonesSection({
</CardHeader>
<CardContent>
<div className="space-y-3">
{milestones.map((milestone) => {
{milestones.map((milestone: { id: string; name: string; description: string | null; isRequired: boolean; myCompletions: { completedAt: Date }[] }) => {
const isCompleted = milestone.myCompletions.length > 0
const isPending = completeMutation.isPending || uncompleteMutation.isPending
@ -752,7 +752,7 @@ function NotesSection({ mentorAssignmentId }: { mentorAssignmentId: string }) {
</div>
) : notes && notes.length > 0 ? (
<div className="space-y-3">
{notes.map((note) => (
{notes.map((note: { id: string; content: string; isVisibleToAdmin: boolean; createdAt: Date }) => (
<div
key={note.id}
className="p-4 rounded-lg border space-y-2"

View File

@ -208,9 +208,9 @@ export function MembersContent() {
<TableCell>
<div>
{user.role === 'MENTOR' ? (
<p>{user._count.mentorAssignments} mentored</p>
<p>{(user as unknown as { _count: { mentorAssignments: number; assignments: number } })._count.mentorAssignments} mentored</p>
) : (
<p>{user._count.assignments} assigned</p>
<p>{(user as unknown as { _count: { mentorAssignments: number; assignments: number } })._count.assignments} assigned</p>
)}
</div>
</TableCell>
@ -276,8 +276,8 @@ export function MembersContent() {
<span className="text-muted-foreground">Assignments</span>
<span>
{user.role === 'MENTOR'
? `${user._count.mentorAssignments} mentored`
: `${user._count.assignments} assigned`}
? `${(user as unknown as { _count: { mentorAssignments: number; assignments: number } })._count.mentorAssignments} mentored`
: `${(user as unknown as { _count: { mentorAssignments: number; assignments: number } })._count.assignments} assigned`}
</span>
</div>
{user.expertiseTags && user.expertiseTags.length > 0 && (

View File

@ -676,6 +676,7 @@ export const applicationRouter = router({
// Create new draft project
const project = await ctx.prisma.project.create({
data: {
programId: round.programId,
roundId: round.id,
title: input.title || 'Untitled Draft',
isDraft: true,
@ -795,8 +796,8 @@ export const applicationRouter = router({
title: data.projectName,
teamName: data.teamName,
description: data.description,
competitionCategory: data.competitionCategory,
oceanIssue: data.oceanIssue,
competitionCategory: data.competitionCategory as CompetitionCategory,
oceanIssue: data.oceanIssue as OceanIssue,
country: data.country,
geographicZone: data.city ? `${data.city}, ${data.country}` : data.country,
institution: data.institution,
@ -838,7 +839,7 @@ export const applicationRouter = router({
return {
success: true,
projectId: updated.id,
message: `Thank you for applying to ${project.round.program.name}!`,
message: `Thank you for applying to ${project.round?.program.name ?? 'the program'}!`,
}
}),

View File

@ -863,7 +863,7 @@ export const evaluationRouter = router({
const settings = (round.settingsJson as Record<string, unknown>) || {}
const anonymizationLevel = (settings.anonymization_level as string) || 'fully_anonymous'
const anonymizedComments = discussion.comments.map((c, idx) => {
const anonymizedComments = discussion.comments.map((c: { id: string; userId: string; user: { name: string | null }; content: string; createdAt: Date }, idx: number) => {
let authorLabel: string
if (anonymizationLevel === 'named' || c.userId === ctx.user.id) {
authorLabel = c.user.name || 'Juror'
@ -871,7 +871,7 @@ export const evaluationRouter = router({
const name = c.user.name || ''
authorLabel = name
.split(' ')
.map((n) => n[0])
.map((n: string) => n[0])
.join('')
.toUpperCase() || 'J'
} else {

View File

@ -405,8 +405,8 @@ export const liveVotingRouter = router({
.map((jurySc) => {
const project = projects.find((p) => p.id === jurySc.projectId)
const audienceSc = audienceMap.get(jurySc.projectId)
const juryAvg = jurySc._avg.score || 0
const audienceAvg = audienceSc?._avg.score || 0
const juryAvg = jurySc._avg?.score || 0
const audienceAvg = audienceSc?._avg?.score || 0
const weightedTotal = audienceWeight > 0 && audienceSc
? juryAvg * juryWeight + audienceAvg * audienceWeight
: juryAvg

View File

@ -981,9 +981,9 @@ export const mentorRouter = router({
})
const myAssignmentIds = new Set(myAssignments.map((a) => a.id))
return milestones.map((milestone) => ({
return milestones.map((milestone: typeof milestones[number]) => ({
...milestone,
myCompletions: milestone.completions.filter((c) =>
myCompletions: milestone.completions.filter((c: { mentorAssignmentId: string }) =>
myAssignmentIds.has(c.mentorAssignmentId)
),
}))
@ -1036,7 +1036,7 @@ export const mentorRouter = router({
const completedMilestones = await ctx.prisma.mentorMilestoneCompletion.findMany({
where: {
mentorAssignmentId: input.mentorAssignmentId,
milestoneId: { in: requiredMilestones.map((m) => m.id) },
milestoneId: { in: requiredMilestones.map((m: { id: string }) => m.id) },
},
select: { milestoneId: true },
})
@ -1057,7 +1057,7 @@ export const mentorRouter = router({
userId: ctx.user.id,
action: 'COMPLETE_MILESTONE',
entityType: 'MentorMilestoneCompletion',
entityId: completion.id,
entityId: `${completion.milestoneId}_${completion.mentorAssignmentId}`,
detailsJson: {
milestoneId: input.milestoneId,
mentorAssignmentId: input.mentorAssignmentId,
@ -1243,7 +1243,7 @@ export const mentorRouter = router({
mentor: { select: { id: true, name: true, email: true } },
project: { select: { id: true, title: true } },
notes: { select: { id: true } },
milestoneCompletions: { select: { id: true } },
milestoneCompletions: { select: { milestoneId: true } },
},
})

View File

@ -247,7 +247,7 @@ export const messageRouter = router({
subject: input.subject,
body: input.body,
variables: input.variables ?? undefined,
createdBy: ctx.user.id,
createdById: ctx.user.id,
},
})