Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n

Features implemented:
- F1: Email digest notifications with cron endpoint and per-user frequency
- F2: Jury availability windows and workload preferences in smart assignment
- F3: Round templates with save-from-round and CRUD management
- F4: Side-by-side project comparison view for jury members
- F5: Real-time voting dashboard with Server-Sent Events (SSE)
- F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations
- F7: File versioning, inline preview, bulk download with presigned URLs
- F8: Mentor dashboard: milestones, private notes, activity tracking
- F9: Communication hub with broadcasts, templates, and recipient targeting
- F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export
- F11: Applicant draft saving with magic link resume and cron cleanup
- F12: Webhook integration layer with HMAC signing, retry, and delivery logs
- F13: Peer review discussions with anonymized scores and threaded comments
- F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention
- F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher

Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program
New routers: roundTemplate, message, webhook (registered in _app.ts)
New services: email-digest, webhook-dispatcher
New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup
New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download

All features are admin-configurable via SystemSettings or per-model settingsJson fields.
Docker build verified successfully.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 23:31:41 +01:00
parent f038c95777
commit 59436ed67a
68 changed files with 14541 additions and 546 deletions

View File

@@ -29,6 +29,10 @@ import { mentorRouter } from './mentor'
import { filteringRouter } from './filtering'
import { specialAwardRouter } from './specialAward'
import { notificationRouter } from './notification'
// Feature expansion routers
import { roundTemplateRouter } from './roundTemplate'
import { messageRouter } from './message'
import { webhookRouter } from './webhook'
/**
* Root tRPC router that combines all domain routers
@@ -64,6 +68,10 @@ export const appRouter = router({
filtering: filteringRouter,
specialAward: specialAwardRouter,
notification: notificationRouter,
// Feature expansion routers
roundTemplate: roundTemplateRouter,
message: messageRouter,
webhook: webhookRouter,
})
export type AppRouter = typeof appRouter

View File

@@ -366,4 +366,272 @@ export const analyticsRouter = router({
count: d._count.id,
}))
}),
// =========================================================================
// Advanced Analytics (F10)
// =========================================================================
/**
* Compare metrics across multiple rounds
*/
getCrossRoundComparison: observerProcedure
.input(z.object({ roundIds: z.array(z.string()).min(2) }))
.query(async ({ ctx, input }) => {
const comparisons = await Promise.all(
input.roundIds.map(async (roundId) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { id: true, name: true },
})
const [projectCount, assignmentCount, evaluationCount] = await Promise.all([
ctx.prisma.project.count({ where: { roundId } }),
ctx.prisma.assignment.count({ where: { roundId } }),
ctx.prisma.evaluation.count({
where: {
assignment: { roundId },
status: 'SUBMITTED',
},
}),
])
const completionRate = assignmentCount > 0
? Math.round((evaluationCount / assignmentCount) * 100)
: 0
// Get average scores
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId },
status: 'SUBMITTED',
},
select: { globalScore: true },
})
const globalScores = evaluations
.map((e) => e.globalScore)
.filter((s): s is number => s !== null)
const averageScore = globalScores.length > 0
? globalScores.reduce((a, b) => a + b, 0) / globalScores.length
: null
// Score distribution
const distribution = Array.from({ length: 10 }, (_, i) => ({
score: i + 1,
count: globalScores.filter((s) => Math.round(s) === i + 1).length,
}))
return {
roundId,
roundName: round.name,
projectCount,
evaluationCount,
completionRate,
averageScore,
scoreDistribution: distribution,
}
})
)
return comparisons
}),
/**
* Get juror consistency metrics for a round
*/
getJurorConsistency: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
status: 'SUBMITTED',
},
include: {
assignment: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
},
})
// Group scores by juror
const jurorScores: Record<string, { name: string; email: string; scores: number[] }> = {}
evaluations.forEach((e) => {
const userId = e.assignment.userId
if (!jurorScores[userId]) {
jurorScores[userId] = {
name: e.assignment.user.name || e.assignment.user.email || 'Unknown',
email: e.assignment.user.email || '',
scores: [],
}
}
if (e.globalScore !== null) {
jurorScores[userId].scores.push(e.globalScore)
}
})
// Calculate overall average
const allScores = Object.values(jurorScores).flatMap((j) => j.scores)
const overallAverage = allScores.length > 0
? allScores.reduce((a, b) => a + b, 0) / allScores.length
: 0
// Calculate per-juror metrics
const metrics = Object.entries(jurorScores).map(([userId, data]) => {
const avg = data.scores.length > 0
? data.scores.reduce((a, b) => a + b, 0) / data.scores.length
: 0
const variance = data.scores.length > 1
? data.scores.reduce((sum, s) => sum + Math.pow(s - avg, 2), 0) / data.scores.length
: 0
const stddev = Math.sqrt(variance)
const deviationFromOverall = Math.abs(avg - overallAverage)
return {
userId,
name: data.name,
email: data.email,
evaluationCount: data.scores.length,
averageScore: avg,
stddev,
deviationFromOverall,
isOutlier: deviationFromOverall > 2, // Flag if 2+ points from mean
}
})
return {
overallAverage,
jurors: metrics.sort((a, b) => b.deviationFromOverall - a.deviationFromOverall),
}
}),
/**
* Get diversity metrics for projects in a round
*/
getDiversityMetrics: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
select: {
country: true,
competitionCategory: true,
oceanIssue: true,
tags: true,
},
})
const total = projects.length
if (total === 0) {
return { total: 0, byCountry: [], byCategory: [], byOceanIssue: [], byTag: [] }
}
// By country
const countryCounts: Record<string, number> = {}
projects.forEach((p) => {
const key = p.country || 'Unknown'
countryCounts[key] = (countryCounts[key] || 0) + 1
})
const byCountry = Object.entries(countryCounts)
.map(([country, count]) => ({ country, count, percentage: (count / total) * 100 }))
.sort((a, b) => b.count - a.count)
// By competition category
const categoryCounts: Record<string, number> = {}
projects.forEach((p) => {
const key = p.competitionCategory || 'Uncategorized'
categoryCounts[key] = (categoryCounts[key] || 0) + 1
})
const byCategory = Object.entries(categoryCounts)
.map(([category, count]) => ({ category, count, percentage: (count / total) * 100 }))
.sort((a, b) => b.count - a.count)
// By ocean issue
const issueCounts: Record<string, number> = {}
projects.forEach((p) => {
const key = p.oceanIssue || 'Unspecified'
issueCounts[key] = (issueCounts[key] || 0) + 1
})
const byOceanIssue = Object.entries(issueCounts)
.map(([issue, count]) => ({ issue, count, percentage: (count / total) * 100 }))
.sort((a, b) => b.count - a.count)
// By tag
const tagCounts: Record<string, number> = {}
projects.forEach((p) => {
p.tags.forEach((tag) => {
tagCounts[tag] = (tagCounts[tag] || 0) + 1
})
})
const byTag = Object.entries(tagCounts)
.map(([tag, count]) => ({ tag, count, percentage: (count / total) * 100 }))
.sort((a, b) => b.count - a.count)
return { total, byCountry, byCategory, byOceanIssue, byTag }
}),
/**
* Get year-over-year stats across all rounds in a program
*/
getYearOverYear: observerProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const rounds = await ctx.prisma.round.findMany({
where: { programId: input.programId },
select: { id: true, name: true, createdAt: true },
orderBy: { createdAt: 'asc' },
})
const stats = await Promise.all(
rounds.map(async (round) => {
const [projectCount, evaluationCount, assignmentCount] = await Promise.all([
ctx.prisma.project.count({ where: { roundId: round.id } }),
ctx.prisma.evaluation.count({
where: {
assignment: { roundId: round.id },
status: 'SUBMITTED',
},
}),
ctx.prisma.assignment.count({ where: { roundId: round.id } }),
])
const completionRate = assignmentCount > 0
? Math.round((evaluationCount / assignmentCount) * 100)
: 0
// Average score
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: round.id },
status: 'SUBMITTED',
},
select: { globalScore: true },
})
const scores = evaluations
.map((e) => e.globalScore)
.filter((s): s is number => s !== null)
const averageScore = scores.length > 0
? scores.reduce((a, b) => a + b, 0) / scores.length
: null
return {
roundId: round.id,
roundName: round.name,
createdAt: round.createdAt,
projectCount,
evaluationCount,
completionRate,
averageScore,
}
})
)
return stats
}),
})

View File

@@ -1,7 +1,7 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, publicProcedure } from '../trpc'
import { CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma/client'
import { Prisma, CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma/client'
import {
createNotification,
notifyAdmins,
@@ -386,4 +386,278 @@ export const applicationRouter = router({
: null,
}
}),
// =========================================================================
// Draft Saving & Resume (F11)
// =========================================================================
/**
* Save application as draft with resume token
*/
saveDraft: publicProcedure
.input(
z.object({
roundSlug: z.string(),
email: z.string().email(),
draftDataJson: z.record(z.unknown()),
title: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Find round by slug
const round = await ctx.prisma.round.findFirst({
where: { slug: input.roundSlug },
})
if (!round) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Round not found',
})
}
// Check if drafts are enabled
const settings = (round.settingsJson as Record<string, unknown>) || {}
if (settings.drafts_enabled === false) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Draft saving is not enabled for this round',
})
}
// Calculate draft expiry
const draftExpiryDays = (settings.draft_expiry_days as number) || 30
const draftExpiresAt = new Date()
draftExpiresAt.setDate(draftExpiresAt.getDate() + draftExpiryDays)
// Generate resume token
const draftToken = `draft_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`
// Find or create draft project for this email+round
const existingDraft = await ctx.prisma.project.findFirst({
where: {
roundId: round.id,
submittedByEmail: input.email,
isDraft: true,
},
})
if (existingDraft) {
// Update existing draft
const updated = await ctx.prisma.project.update({
where: { id: existingDraft.id },
data: {
title: input.title || existingDraft.title,
draftDataJson: input.draftDataJson as Prisma.InputJsonValue,
draftExpiresAt,
metadataJson: {
...((existingDraft.metadataJson as Record<string, unknown>) || {}),
draftToken,
} as Prisma.InputJsonValue,
},
})
return { projectId: updated.id, draftToken }
}
// Create new draft project
const project = await ctx.prisma.project.create({
data: {
roundId: round.id,
title: input.title || 'Untitled Draft',
isDraft: true,
draftDataJson: input.draftDataJson as Prisma.InputJsonValue,
draftExpiresAt,
submittedByEmail: input.email,
metadataJson: {
draftToken,
},
},
})
return { projectId: project.id, draftToken }
}),
/**
* Resume a draft application using a token
*/
resumeDraft: publicProcedure
.input(z.object({ draftToken: z.string() }))
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: {
isDraft: true,
},
})
// Find project with matching token in metadataJson
const project = projects.find((p) => {
const metadata = p.metadataJson as Record<string, unknown> | null
return metadata?.draftToken === input.draftToken
})
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Draft not found or invalid token',
})
}
// Check expiry
if (project.draftExpiresAt && new Date() > project.draftExpiresAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'This draft has expired',
})
}
return {
projectId: project.id,
draftDataJson: project.draftDataJson,
title: project.title,
roundId: project.roundId,
}
}),
/**
* Submit a saved draft as a final application
*/
submitDraft: publicProcedure
.input(
z.object({
projectId: z.string(),
draftToken: z.string(),
data: applicationSchema,
})
)
.mutation(async ({ ctx, input }) => {
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
include: { round: { include: { program: true } } },
})
// Verify token
const metadata = (project.metadataJson as Record<string, unknown>) || {}
if (metadata.draftToken !== input.draftToken) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Invalid draft token',
})
}
if (!project.isDraft) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'This project has already been submitted',
})
}
const now = new Date()
const { data } = input
// Find or create user
let user = await ctx.prisma.user.findUnique({
where: { email: data.contactEmail },
})
if (!user) {
user = await ctx.prisma.user.create({
data: {
email: data.contactEmail,
name: data.contactName,
role: 'APPLICANT',
status: 'ACTIVE',
phoneNumber: data.contactPhone,
},
})
}
// Update project with final data
const updated = await ctx.prisma.project.update({
where: { id: input.projectId },
data: {
isDraft: false,
draftDataJson: Prisma.DbNull,
draftExpiresAt: null,
title: data.projectName,
teamName: data.teamName,
description: data.description,
competitionCategory: data.competitionCategory,
oceanIssue: data.oceanIssue,
country: data.country,
geographicZone: data.city ? `${data.city}, ${data.country}` : data.country,
institution: data.institution,
wantsMentorship: data.wantsMentorship,
referralSource: data.referralSource,
submissionSource: 'PUBLIC_FORM',
submittedByEmail: data.contactEmail,
submittedByUserId: user.id,
submittedAt: now,
status: 'SUBMITTED',
metadataJson: {
contactPhone: data.contactPhone,
startupCreatedDate: data.startupCreatedDate,
gdprConsentAt: now.toISOString(),
},
},
})
// Audit log
try {
await logAudit({
prisma: ctx.prisma,
userId: user.id,
action: 'DRAFT_SUBMITTED',
entityType: 'Project',
entityId: updated.id,
detailsJson: {
source: 'draft_submission',
title: data.projectName,
category: data.competitionCategory,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Never throw on audit failure
}
return {
success: true,
projectId: updated.id,
message: `Thank you for applying to ${project.round.program.name}!`,
}
}),
/**
* Get a read-only preview of draft data
*/
getPreview: publicProcedure
.input(z.object({ draftToken: z.string() }))
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: {
isDraft: true,
},
})
const project = projects.find((p) => {
const metadata = p.metadataJson as Record<string, unknown> | null
return metadata?.draftToken === input.draftToken
})
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Draft not found or invalid token',
})
}
return {
title: project.title,
draftDataJson: project.draftDataJson,
createdAt: project.createdAt,
expiresAt: project.draftExpiresAt,
}
}),
})

View File

@@ -1,5 +1,6 @@
import { z } from 'zod'
import { router, adminProcedure } from '../trpc'
import { router, adminProcedure, superAdminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
export const auditRouter = router({
/**
@@ -181,4 +182,158 @@ export const auditRouter = router({
})),
}
}),
// =========================================================================
// Anomaly Detection & Session Tracking (F14)
// =========================================================================
/**
* Detect anomalous activity patterns within a time window
*/
getAnomalies: adminProcedure
.input(
z.object({
timeWindowMinutes: z.number().int().min(1).max(1440).default(60),
})
)
.query(async ({ ctx, input }) => {
// Load anomaly rules from settings
const rulesSetting = await ctx.prisma.systemSettings.findUnique({
where: { key: 'audit_anomaly_rules' },
})
const rules = rulesSetting?.value
? JSON.parse(rulesSetting.value) as {
rapid_changes_per_minute?: number
bulk_operations_threshold?: number
}
: { rapid_changes_per_minute: 30, bulk_operations_threshold: 50 }
const rapidThreshold = rules.rapid_changes_per_minute || 30
const bulkThreshold = rules.bulk_operations_threshold || 50
const windowStart = new Date()
windowStart.setMinutes(windowStart.getMinutes() - input.timeWindowMinutes)
// Get action counts per user in the time window
const userActivity = await ctx.prisma.auditLog.groupBy({
by: ['userId'],
where: {
timestamp: { gte: windowStart },
userId: { not: null },
},
_count: true,
})
// Filter for users exceeding thresholds
const suspiciousUserIds = userActivity
.filter((u) => u._count >= bulkThreshold)
.map((u) => u.userId)
.filter((id): id is string => id !== null)
// Get user details
const users = suspiciousUserIds.length > 0
? await ctx.prisma.user.findMany({
where: { id: { in: suspiciousUserIds } },
select: { id: true, name: true, email: true, role: true },
})
: []
const userMap = new Map(users.map((u) => [u.id, u]))
const anomalies = userActivity
.filter((u) => u._count >= bulkThreshold)
.map((u) => ({
userId: u.userId,
user: u.userId ? userMap.get(u.userId) || null : null,
actionCount: u._count,
timeWindowMinutes: input.timeWindowMinutes,
actionsPerMinute: u._count / input.timeWindowMinutes,
isRapid: (u._count / input.timeWindowMinutes) >= rapidThreshold,
isBulk: u._count >= bulkThreshold,
}))
.sort((a, b) => b.actionCount - a.actionCount)
return {
anomalies,
thresholds: {
rapidChangesPerMinute: rapidThreshold,
bulkOperationsThreshold: bulkThreshold,
},
timeWindow: {
start: windowStart,
end: new Date(),
minutes: input.timeWindowMinutes,
},
}
}),
/**
* Get all audit logs for a specific session
*/
getSessionTimeline: adminProcedure
.input(z.object({ sessionId: z.string() }))
.query(async ({ ctx, input }) => {
const logs = await ctx.prisma.auditLog.findMany({
where: { sessionId: input.sessionId },
orderBy: { timestamp: 'asc' },
include: {
user: { select: { id: true, name: true, email: true } },
},
})
return logs
}),
/**
* Get current audit retention configuration
*/
getRetentionConfig: adminProcedure.query(async ({ ctx }) => {
const setting = await ctx.prisma.systemSettings.findUnique({
where: { key: 'audit_retention_days' },
})
return {
retentionDays: setting?.value ? parseInt(setting.value, 10) : 365,
}
}),
/**
* Update audit retention configuration (super admin only)
*/
updateRetentionConfig: superAdminProcedure
.input(z.object({ retentionDays: z.number().int().min(30) }))
.mutation(async ({ ctx, input }) => {
const setting = await ctx.prisma.systemSettings.upsert({
where: { key: 'audit_retention_days' },
update: {
value: input.retentionDays.toString(),
updatedBy: ctx.user.id,
},
create: {
key: 'audit_retention_days',
value: input.retentionDays.toString(),
category: 'AUDIT_CONFIG',
updatedBy: ctx.user.id,
},
})
// Audit log
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_RETENTION_CONFIG',
entityType: 'SystemSettings',
entityId: setting.id,
detailsJson: { retentionDays: input.retentionDays },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Never throw on audit failure
}
return { retentionDays: input.retentionDays }
}),
})

View File

@@ -1,6 +1,6 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { router, protectedProcedure, adminProcedure, juryProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
import { notifyAdmins, NotificationTypes } from '../services/in-app-notification'
import { processEvaluationReminders } from '../services/evaluation-reminders'
@@ -621,4 +621,397 @@ export const evaluationRouter = router({
errors,
}
}),
// =========================================================================
// Side-by-Side Comparison (F4)
// =========================================================================
/**
* Get multiple projects with evaluations for side-by-side comparison
*/
getMultipleForComparison: juryProcedure
.input(
z.object({
projectIds: z.array(z.string()).min(2).max(3),
roundId: z.string(),
})
)
.query(async ({ ctx, input }) => {
// Verify all projects are assigned to current user in this round
const assignments = await ctx.prisma.assignment.findMany({
where: {
userId: ctx.user.id,
roundId: input.roundId,
projectId: { in: input.projectIds },
},
include: {
project: {
select: {
id: true,
title: true,
teamName: true,
description: true,
country: true,
tags: true,
files: {
select: {
id: true,
fileName: true,
fileType: true,
size: true,
},
},
},
},
evaluation: true,
},
})
if (assignments.length !== input.projectIds.length) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to all requested projects in this round',
})
}
return assignments.map((a) => ({
project: a.project,
evaluation: a.evaluation,
assignmentId: a.id,
}))
}),
// =========================================================================
// Peer Review & Discussion (F13)
// =========================================================================
/**
* Get anonymized peer evaluation summary for a project
*/
getPeerSummary: juryProcedure
.input(
z.object({
projectId: z.string(),
roundId: z.string(),
})
)
.query(async ({ ctx, input }) => {
// Verify user has submitted their own evaluation first
const userAssignment = await ctx.prisma.assignment.findFirst({
where: {
userId: ctx.user.id,
projectId: input.projectId,
roundId: input.roundId,
},
include: { evaluation: true },
})
if (!userAssignment || userAssignment.evaluation?.status !== 'SUBMITTED') {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'You must submit your own evaluation before viewing peer summaries',
})
}
// Check round settings for peer review
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
const settings = (round.settingsJson as Record<string, unknown>) || {}
if (!settings.peer_review_enabled) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Peer review is not enabled for this round',
})
}
// Get all submitted evaluations for this project
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
status: 'SUBMITTED',
assignment: {
projectId: input.projectId,
roundId: input.roundId,
},
},
include: {
assignment: {
include: {
user: { select: { id: true, name: true } },
},
},
},
})
if (evaluations.length === 0) {
return { aggregated: null, individualScores: [], totalEvaluations: 0 }
}
// Calculate average and stddev per criterion
const criterionData: Record<string, number[]> = {}
evaluations.forEach((e) => {
const scores = e.criterionScoresJson as Record<string, number> | null
if (scores) {
Object.entries(scores).forEach(([key, val]) => {
if (typeof val === 'number') {
if (!criterionData[key]) criterionData[key] = []
criterionData[key].push(val)
}
})
}
})
const aggregated: Record<string, { average: number; stddev: number; count: number; distribution: Record<number, number> }> = {}
Object.entries(criterionData).forEach(([key, scores]) => {
const avg = scores.reduce((a, b) => a + b, 0) / scores.length
const variance = scores.reduce((sum, s) => sum + Math.pow(s - avg, 2), 0) / scores.length
const stddev = Math.sqrt(variance)
const distribution: Record<number, number> = {}
scores.forEach((s) => {
const bucket = Math.round(s)
distribution[bucket] = (distribution[bucket] || 0) + 1
})
aggregated[key] = { average: avg, stddev, count: scores.length, distribution }
})
// Anonymize individual scores based on round settings
const anonymizationLevel = (settings.anonymization_level as string) || 'fully_anonymous'
const individualScores = evaluations.map((e) => {
let jurorLabel: string
if (anonymizationLevel === 'named') {
jurorLabel = e.assignment.user.name || 'Juror'
} else if (anonymizationLevel === 'show_initials') {
const name = e.assignment.user.name || ''
jurorLabel = name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase() || 'J'
} else {
jurorLabel = `Juror ${evaluations.indexOf(e) + 1}`
}
return {
jurorLabel,
globalScore: e.globalScore,
binaryDecision: e.binaryDecision,
criterionScoresJson: e.criterionScoresJson,
}
})
return {
aggregated,
individualScores,
totalEvaluations: evaluations.length,
}
}),
/**
* Get or create a discussion for a project evaluation
*/
getDiscussion: juryProcedure
.input(
z.object({
projectId: z.string(),
roundId: z.string(),
})
)
.query(async ({ ctx, input }) => {
// Get or create discussion
let discussion = await ctx.prisma.evaluationDiscussion.findUnique({
where: {
projectId_roundId: {
projectId: input.projectId,
roundId: input.roundId,
},
},
include: {
comments: {
include: {
user: { select: { id: true, name: true } },
},
orderBy: { createdAt: 'asc' },
},
},
})
if (!discussion) {
discussion = await ctx.prisma.evaluationDiscussion.create({
data: {
projectId: input.projectId,
roundId: input.roundId,
},
include: {
comments: {
include: {
user: { select: { id: true, name: true } },
},
orderBy: { createdAt: 'asc' },
},
},
})
}
// Anonymize comments based on round settings
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
const settings = (round.settingsJson as Record<string, unknown>) || {}
const anonymizationLevel = (settings.anonymization_level as string) || 'fully_anonymous'
const anonymizedComments = discussion.comments.map((c, idx) => {
let authorLabel: string
if (anonymizationLevel === 'named' || c.userId === ctx.user.id) {
authorLabel = c.user.name || 'Juror'
} else if (anonymizationLevel === 'show_initials') {
const name = c.user.name || ''
authorLabel = name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase() || 'J'
} else {
authorLabel = `Juror ${idx + 1}`
}
return {
id: c.id,
authorLabel,
isOwn: c.userId === ctx.user.id,
content: c.content,
createdAt: c.createdAt,
}
})
return {
id: discussion.id,
status: discussion.status,
createdAt: discussion.createdAt,
closedAt: discussion.closedAt,
comments: anonymizedComments,
}
}),
/**
* Add a comment to a project evaluation discussion
*/
addComment: juryProcedure
.input(
z.object({
projectId: z.string(),
roundId: z.string(),
content: z.string().min(1).max(2000),
})
)
.mutation(async ({ ctx, input }) => {
// Check max comment length from round settings
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
const settings = (round.settingsJson as Record<string, unknown>) || {}
const maxLength = (settings.max_comment_length as number) || 2000
if (input.content.length > maxLength) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Comment exceeds maximum length of ${maxLength} characters`,
})
}
// Get or create discussion
let discussion = await ctx.prisma.evaluationDiscussion.findUnique({
where: {
projectId_roundId: {
projectId: input.projectId,
roundId: input.roundId,
},
},
})
if (!discussion) {
discussion = await ctx.prisma.evaluationDiscussion.create({
data: {
projectId: input.projectId,
roundId: input.roundId,
},
})
}
if (discussion.status === 'closed') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'This discussion has been closed',
})
}
const comment = await ctx.prisma.discussionComment.create({
data: {
discussionId: discussion.id,
userId: ctx.user.id,
content: input.content,
},
})
// Audit log
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DISCUSSION_COMMENT_ADDED',
entityType: 'DiscussionComment',
entityId: comment.id,
detailsJson: {
discussionId: discussion.id,
projectId: input.projectId,
roundId: input.roundId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Never throw on audit failure
}
return comment
}),
/**
* Close a discussion (admin only)
*/
closeDiscussion: adminProcedure
.input(z.object({ discussionId: z.string() }))
.mutation(async ({ ctx, input }) => {
const discussion = await ctx.prisma.evaluationDiscussion.update({
where: { id: input.discussionId },
data: {
status: 'closed',
closedAt: new Date(),
closedById: ctx.user.id,
},
})
// Audit log
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DISCUSSION_CLOSED',
entityType: 'EvaluationDiscussion',
entityId: input.discussionId,
detailsJson: {
projectId: discussion.projectId,
roundId: discussion.roundId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Never throw on audit failure
}
return discussion
}),
})

View File

@@ -1,5 +1,5 @@
import { z } from 'zod'
import { router, adminProcedure } from '../trpc'
import { router, adminProcedure, observerProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
export const exportRouter = router({
@@ -388,4 +388,234 @@ export const exportRouter = router({
],
}
}),
// =========================================================================
// PDF Report Data (F10)
// =========================================================================
/**
* Compile structured data for PDF report generation
*/
getReportData: observerProcedure
.input(
z.object({
roundId: z.string(),
sections: z.array(z.string()).optional(),
})
)
.query(async ({ ctx, input }) => {
const includeSection = (name: string) =>
!input.sections || input.sections.length === 0 || input.sections.includes(name)
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
include: {
program: { select: { name: true, year: true } },
},
})
const result: Record<string, unknown> = {
roundName: round.name,
programName: round.program.name,
programYear: round.program.year,
generatedAt: new Date().toISOString(),
}
// Summary stats
if (includeSection('summary')) {
const [projectCount, assignmentCount, evaluationCount, jurorCount] = await Promise.all([
ctx.prisma.project.count({ where: { roundId: input.roundId } }),
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
ctx.prisma.evaluation.count({
where: {
assignment: { roundId: input.roundId },
status: 'SUBMITTED',
},
}),
ctx.prisma.assignment.groupBy({
by: ['userId'],
where: { roundId: input.roundId },
}),
])
result.summary = {
projectCount,
assignmentCount,
evaluationCount,
jurorCount: jurorCount.length,
completionRate: assignmentCount > 0
? Math.round((evaluationCount / assignmentCount) * 100)
: 0,
}
}
// Score distributions
if (includeSection('scoreDistribution')) {
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
status: 'SUBMITTED',
},
select: { globalScore: true },
})
const scores = evaluations
.map((e) => e.globalScore)
.filter((s): s is number => s !== null)
result.scoreDistribution = {
distribution: Array.from({ length: 10 }, (_, i) => ({
score: i + 1,
count: scores.filter((s) => Math.round(s) === i + 1).length,
})),
average: scores.length > 0
? scores.reduce((a, b) => a + b, 0) / scores.length
: null,
total: scores.length,
}
}
// Rankings
if (includeSection('rankings')) {
const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
select: {
id: true,
title: true,
teamName: true,
status: true,
assignments: {
select: {
evaluation: {
select: { globalScore: true, binaryDecision: true, status: true },
},
},
},
},
})
const rankings = projects
.map((p) => {
const submitted = p.assignments
.map((a) => a.evaluation)
.filter((e) => e?.status === 'SUBMITTED')
const scores = submitted
.map((e) => e?.globalScore)
.filter((s): s is number => s !== null)
const yesVotes = submitted.filter((e) => e?.binaryDecision === true).length
return {
title: p.title,
teamName: p.teamName,
status: p.status,
evaluationCount: submitted.length,
averageScore: scores.length > 0
? scores.reduce((a, b) => a + b, 0) / scores.length
: null,
yesPercentage: submitted.length > 0
? (yesVotes / submitted.length) * 100
: null,
}
})
.filter((r) => r.averageScore !== null)
.sort((a, b) => (b.averageScore || 0) - (a.averageScore || 0))
result.rankings = rankings
}
// Juror stats
if (includeSection('jurorStats')) {
const assignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
include: {
user: { select: { name: true, email: true } },
evaluation: { select: { status: true, globalScore: true } },
},
})
const byUser: Record<string, { name: string; assigned: number; completed: number; scores: number[] }> = {}
assignments.forEach((a) => {
if (!byUser[a.userId]) {
byUser[a.userId] = {
name: a.user.name || a.user.email || 'Unknown',
assigned: 0,
completed: 0,
scores: [],
}
}
byUser[a.userId].assigned++
if (a.evaluation?.status === 'SUBMITTED') {
byUser[a.userId].completed++
if (a.evaluation.globalScore !== null) {
byUser[a.userId].scores.push(a.evaluation.globalScore)
}
}
})
result.jurorStats = Object.values(byUser).map((u) => ({
name: u.name,
assigned: u.assigned,
completed: u.completed,
completionRate: u.assigned > 0 ? Math.round((u.completed / u.assigned) * 100) : 0,
averageScore: u.scores.length > 0
? u.scores.reduce((a, b) => a + b, 0) / u.scores.length
: null,
}))
}
// Criteria breakdown
if (includeSection('criteriaBreakdown')) {
const form = await ctx.prisma.evaluationForm.findFirst({
where: { roundId: input.roundId, isActive: true },
})
if (form?.criteriaJson) {
const criteria = form.criteriaJson as Array<{ id: string; label: string }>
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
status: 'SUBMITTED',
},
select: { criterionScoresJson: true },
})
result.criteriaBreakdown = criteria.map((c) => {
const scores: number[] = []
evaluations.forEach((e) => {
const cs = e.criterionScoresJson as Record<string, number> | null
if (cs && typeof cs[c.id] === 'number') {
scores.push(cs[c.id])
}
})
return {
id: c.id,
label: c.label,
averageScore: scores.length > 0
? scores.reduce((a, b) => a + b, 0) / scores.length
: null,
count: scores.length,
}
})
}
}
// Audit log for report generation
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'REPORT_GENERATED',
entityType: 'Round',
entityId: input.roundId,
detailsJson: { sections: input.sections },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Never throw on audit failure
}
return result
}),
})

View File

@@ -384,4 +384,267 @@ export const fileRouter = router({
return grouped
}),
/**
* Replace a file with a new version
*/
replaceFile: protectedProcedure
.input(
z.object({
projectId: z.string(),
oldFileId: z.string(),
fileName: z.string(),
fileType: z.enum(['EXEC_SUMMARY', 'PRESENTATION', 'VIDEO', 'OTHER']),
mimeType: z.string(),
size: z.number().int().positive(),
bucket: z.string(),
objectKey: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!isAdmin) {
// Check user has access to the project (assigned or team member)
const [assignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: input.projectId },
select: { id: true },
}),
ctx.prisma.mentorAssignment.findFirst({
where: { mentorId: ctx.user.id, projectId: input.projectId },
select: { id: true },
}),
ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: { id: true },
}),
])
if (!assignment && !mentorAssignment && !teamMembership) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to replace files for this project',
})
}
}
// Get the old file to read its version
const oldFile = await ctx.prisma.projectFile.findUniqueOrThrow({
where: { id: input.oldFileId },
select: { id: true, version: true, projectId: true },
})
if (oldFile.projectId !== input.projectId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'File does not belong to the specified project',
})
}
// Create new file and update old file in a transaction
const result = await ctx.prisma.$transaction(async (tx) => {
const newFile = await tx.projectFile.create({
data: {
projectId: input.projectId,
fileName: input.fileName,
fileType: input.fileType,
mimeType: input.mimeType,
size: input.size,
bucket: input.bucket,
objectKey: input.objectKey,
version: oldFile.version + 1,
},
})
// Link old file to new file
await tx.projectFile.update({
where: { id: input.oldFileId },
data: { replacedById: newFile.id },
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'REPLACE_FILE',
entityType: 'ProjectFile',
entityId: newFile.id,
detailsJson: {
projectId: input.projectId,
oldFileId: input.oldFileId,
oldVersion: oldFile.version,
newVersion: newFile.version,
fileName: input.fileName,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return newFile
})
return result
}),
/**
* Get version history for a file
*/
getVersionHistory: protectedProcedure
.input(z.object({ fileId: z.string() }))
.query(async ({ ctx, input }) => {
// Find the requested file
const file = await ctx.prisma.projectFile.findUniqueOrThrow({
where: { id: input.fileId },
select: {
id: true,
projectId: true,
fileName: true,
fileType: true,
mimeType: true,
size: true,
bucket: true,
objectKey: true,
version: true,
replacedById: true,
createdAt: true,
},
})
// Walk backwards: find all prior versions by following replacedById chains
// First, collect ALL files for this project with the same fileType to find the chain
const allRelatedFiles = await ctx.prisma.projectFile.findMany({
where: { projectId: file.projectId },
select: {
id: true,
fileName: true,
fileType: true,
mimeType: true,
size: true,
bucket: true,
objectKey: true,
version: true,
replacedById: true,
createdAt: true,
},
orderBy: { version: 'asc' },
})
// Build a chain map: fileId -> file that replaced it
const replacedByMap = new Map(
allRelatedFiles.filter((f) => f.replacedById).map((f) => [f.replacedById!, f.id])
)
// Walk from the current file backwards through replacedById to find all versions in chain
const versions: typeof allRelatedFiles = []
// Find the root of this version chain (walk backwards)
let currentId: string | undefined = input.fileId
const visited = new Set<string>()
while (currentId && !visited.has(currentId)) {
visited.add(currentId)
const prevId = replacedByMap.get(currentId)
if (prevId) {
currentId = prevId
} else {
break // reached root
}
}
// Now walk forward from root
let walkId: string | undefined = currentId
const fileMap = new Map(allRelatedFiles.map((f) => [f.id, f]))
const forwardVisited = new Set<string>()
while (walkId && !forwardVisited.has(walkId)) {
forwardVisited.add(walkId)
const f = fileMap.get(walkId)
if (f) {
versions.push(f)
walkId = f.replacedById ?? undefined
} else {
break
}
}
return versions
}),
/**
* Get bulk download URLs for project files
*/
getBulkDownloadUrls: protectedProcedure
.input(
z.object({
projectId: z.string(),
fileIds: z.array(z.string()).optional(),
})
)
.query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!isAdmin) {
const [assignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: input.projectId },
select: { id: true },
}),
ctx.prisma.mentorAssignment.findFirst({
where: { mentorId: ctx.user.id, projectId: input.projectId },
select: { id: true },
}),
ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: { id: true },
}),
])
if (!assignment && !mentorAssignment && !teamMembership) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this project\'s files',
})
}
}
// Get files
const where: Record<string, unknown> = { projectId: input.projectId }
if (input.fileIds && input.fileIds.length > 0) {
where.id = { in: input.fileIds }
}
const files = await ctx.prisma.projectFile.findMany({
where,
select: {
id: true,
fileName: true,
bucket: true,
objectKey: true,
},
})
// Generate signed URLs for each file
const results = await Promise.all(
files.map(async (file) => {
const downloadUrl = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900)
return {
fileId: file.id,
fileName: file.fileName,
downloadUrl,
}
})
)
return results
}),
})

View File

@@ -1,6 +1,6 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { router, protectedProcedure, adminProcedure, publicProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
export const liveVotingRouter = router({
@@ -351,7 +351,7 @@ export const liveVotingRouter = router({
}),
/**
* Get results for a session
* Get results for a session (with weighted jury + audience scoring)
*/
getResults: protectedProcedure
.input(z.object({ sessionId: z.string() }))
@@ -367,36 +367,281 @@ export const liveVotingRouter = router({
},
})
// Get all votes grouped by project
const projectScores = await ctx.prisma.liveVote.groupBy({
const audienceWeight = session.audienceVoteWeight || 0
const juryWeight = 1 - audienceWeight
// Get jury votes grouped by project
const juryScores = await ctx.prisma.liveVote.groupBy({
by: ['projectId'],
where: { sessionId: input.sessionId, isAudienceVote: false },
_avg: { score: true },
_count: true,
})
// Get audience votes grouped by project
const audienceScores = await ctx.prisma.liveVote.groupBy({
by: ['projectId'],
where: { sessionId: input.sessionId, isAudienceVote: true },
_avg: { score: true },
_count: true,
})
// Get project details
const allProjectIds = [
...new Set([
...juryScores.map((s) => s.projectId),
...audienceScores.map((s) => s.projectId),
]),
]
const projects = await ctx.prisma.project.findMany({
where: { id: { in: allProjectIds } },
select: { id: true, title: true, teamName: true },
})
const audienceMap = new Map(audienceScores.map((s) => [s.projectId, s]))
// Combine and calculate weighted scores
const results = juryScores
.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 weightedTotal = audienceWeight > 0 && audienceSc
? juryAvg * juryWeight + audienceAvg * audienceWeight
: juryAvg
return {
project,
juryAverage: juryAvg,
juryVoteCount: jurySc._count,
audienceAverage: audienceAvg,
audienceVoteCount: audienceSc?._count || 0,
weightedTotal,
}
})
.sort((a, b) => b.weightedTotal - a.weightedTotal)
// Detect ties
const ties: string[][] = []
for (let i = 0; i < results.length - 1; i++) {
if (Math.abs(results[i].weightedTotal - results[i + 1].weightedTotal) < 0.001) {
const tieGroup = [results[i].project?.id, results[i + 1].project?.id].filter(Boolean) as string[]
ties.push(tieGroup)
}
}
return {
session,
results,
ties,
tieBreakerMethod: session.tieBreakerMethod,
}
}),
/**
* Update presentation settings for a live voting session
*/
updatePresentationSettings: adminProcedure
.input(
z.object({
sessionId: z.string(),
presentationSettingsJson: z.object({
theme: z.string().optional(),
autoAdvance: z.boolean().optional(),
autoAdvanceDelay: z.number().int().min(5).max(120).optional(),
scoreDisplayFormat: z.enum(['bar', 'number', 'radial']).optional(),
showVoteCount: z.boolean().optional(),
brandingOverlay: z.string().optional(),
}),
})
)
.mutation(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.update({
where: { id: input.sessionId },
data: {
presentationSettingsJson: input.presentationSettingsJson,
},
})
return session
}),
/**
* Update session config (audience voting, tie-breaker)
*/
updateSessionConfig: adminProcedure
.input(
z.object({
sessionId: z.string(),
allowAudienceVotes: z.boolean().optional(),
audienceVoteWeight: z.number().min(0).max(1).optional(),
tieBreakerMethod: z.enum(['admin_decides', 'highest_individual', 'revote']).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { sessionId, ...data } = input
const session = await ctx.prisma.liveVotingSession.update({
where: { id: sessionId },
data,
})
// Audit log
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_SESSION_CONFIG',
entityType: 'LiveVotingSession',
entityId: sessionId,
detailsJson: data,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Audit log errors should never break the operation
}
return session
}),
/**
* Cast an audience vote
*/
castAudienceVote: protectedProcedure
.input(
z.object({
sessionId: z.string(),
projectId: z.string(),
score: z.number().int().min(1).max(10),
})
)
.mutation(async ({ ctx, input }) => {
// Verify session is in progress and allows audience votes
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
})
if (session.status !== 'IN_PROGRESS') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Voting is not currently active',
})
}
if (!session.allowAudienceVotes) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Audience voting is not enabled for this session',
})
}
if (session.currentProjectId !== input.projectId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot vote for this project right now',
})
}
if (session.votingEndsAt && new Date() > session.votingEndsAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Voting window has closed',
})
}
// Upsert audience vote
const vote = await ctx.prisma.liveVote.upsert({
where: {
sessionId_projectId_userId: {
sessionId: input.sessionId,
projectId: input.projectId,
userId: ctx.user.id,
},
},
create: {
sessionId: input.sessionId,
projectId: input.projectId,
userId: ctx.user.id,
score: input.score,
isAudienceVote: true,
},
update: {
score: input.score,
votedAt: new Date(),
},
})
return vote
}),
/**
* Get public results for a live voting session (no auth required)
*/
getPublicResults: publicProcedure
.input(z.object({ sessionId: z.string() }))
.query(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
select: {
id: true,
status: true,
currentProjectId: true,
votingEndsAt: true,
presentationSettingsJson: true,
allowAudienceVotes: true,
audienceVoteWeight: true,
},
})
// Only return data if session is in progress or completed
if (session.status !== 'IN_PROGRESS' && session.status !== 'COMPLETED') {
return {
session: {
id: session.id,
status: session.status,
presentationSettings: session.presentationSettingsJson,
},
projects: [],
}
}
// Get all votes grouped by project (anonymized - no user data)
const scores = await ctx.prisma.liveVote.groupBy({
by: ['projectId'],
where: { sessionId: input.sessionId },
_avg: { score: true },
_count: true,
})
// Get project details
const projectIds = projectScores.map((s) => s.projectId)
const projectIds = scores.map((s) => s.projectId)
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds } },
select: { id: true, title: true, teamName: true },
})
// Combine and sort by average score
const results = projectScores
.map((score) => {
const project = projects.find((p) => p.id === score.projectId)
return {
project,
averageScore: score._avg.score || 0,
voteCount: score._count,
}
})
.sort((a, b) => b.averageScore - a.averageScore)
const projectsWithScores = scores.map((score) => {
const project = projects.find((p) => p.id === score.projectId)
return {
id: project?.id,
title: project?.title,
teamName: project?.teamName,
averageScore: score._avg.score || 0,
voteCount: score._count,
}
}).sort((a, b) => b.averageScore - a.averageScore)
return {
session,
results,
session: {
id: session.id,
status: session.status,
currentProjectId: session.currentProjectId,
votingEndsAt: session.votingEndsAt,
presentationSettings: session.presentationSettingsJson,
allowAudienceVotes: session.allowAudienceVotes,
},
projects: projectsWithScores,
}
}),
})

View File

@@ -794,4 +794,502 @@ export const mentorRouter = router({
totalPages: Math.ceil(total / input.perPage),
}
}),
// =========================================================================
// Mentor Notes CRUD (F8)
// =========================================================================
/**
* Create a mentor note for an assignment
*/
createNote: mentorProcedure
.input(
z.object({
mentorAssignmentId: z.string(),
content: z.string().min(1).max(10000),
isVisibleToAdmin: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
// Verify the user owns this assignment or is admin
const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
where: { id: input.mentorAssignmentId },
select: { mentorId: true, projectId: true },
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (assignment.mentorId !== ctx.user.id && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to this mentorship',
})
}
const note = await ctx.prisma.mentorNote.create({
data: {
mentorAssignmentId: input.mentorAssignmentId,
authorId: ctx.user.id,
content: input.content,
isVisibleToAdmin: input.isVisibleToAdmin,
},
include: {
author: { select: { id: true, name: true, email: true } },
},
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE_MENTOR_NOTE',
entityType: 'MentorNote',
entityId: note.id,
detailsJson: {
mentorAssignmentId: input.mentorAssignmentId,
projectId: assignment.projectId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Audit log errors should never break the operation
}
return note
}),
/**
* Update a mentor note
*/
updateNote: mentorProcedure
.input(
z.object({
noteId: z.string(),
content: z.string().min(1).max(10000),
isVisibleToAdmin: z.boolean().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const note = await ctx.prisma.mentorNote.findUniqueOrThrow({
where: { id: input.noteId },
select: { authorId: true },
})
if (note.authorId !== ctx.user.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You can only edit your own notes',
})
}
return ctx.prisma.mentorNote.update({
where: { id: input.noteId },
data: {
content: input.content,
...(input.isVisibleToAdmin !== undefined && { isVisibleToAdmin: input.isVisibleToAdmin }),
},
include: {
author: { select: { id: true, name: true, email: true } },
},
})
}),
/**
* Delete a mentor note
*/
deleteNote: mentorProcedure
.input(z.object({ noteId: z.string() }))
.mutation(async ({ ctx, input }) => {
const note = await ctx.prisma.mentorNote.findUniqueOrThrow({
where: { id: input.noteId },
select: { authorId: true },
})
if (note.authorId !== ctx.user.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You can only delete your own notes',
})
}
return ctx.prisma.mentorNote.delete({
where: { id: input.noteId },
})
}),
/**
* Get notes for a mentor assignment
*/
getNotes: mentorProcedure
.input(z.object({ mentorAssignmentId: z.string() }))
.query(async ({ ctx, input }) => {
const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
where: { id: input.mentorAssignmentId },
select: { mentorId: true },
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (assignment.mentorId !== ctx.user.id && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to this mentorship',
})
}
// Admins see all notes; mentors see only their own
const where: Record<string, unknown> = { mentorAssignmentId: input.mentorAssignmentId }
if (!isAdmin) {
where.authorId = ctx.user.id
}
return ctx.prisma.mentorNote.findMany({
where,
include: {
author: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: 'desc' },
})
}),
// =========================================================================
// Milestone Operations (F8)
// =========================================================================
/**
* Get milestones for a program with completion status
*/
getMilestones: mentorProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const milestones = await ctx.prisma.mentorMilestone.findMany({
where: { programId: input.programId },
include: {
completions: {
include: {
mentorAssignment: { select: { id: true, projectId: true } },
},
},
},
orderBy: { sortOrder: 'asc' },
})
// Get current user's assignments for completion status context
const myAssignments = await ctx.prisma.mentorAssignment.findMany({
where: { mentorId: ctx.user.id },
select: { id: true, projectId: true },
})
const myAssignmentIds = new Set(myAssignments.map((a) => a.id))
return milestones.map((milestone) => ({
...milestone,
myCompletions: milestone.completions.filter((c) =>
myAssignmentIds.has(c.mentorAssignmentId)
),
}))
}),
/**
* Mark a milestone as completed for an assignment
*/
completeMilestone: mentorProcedure
.input(
z.object({
milestoneId: z.string(),
mentorAssignmentId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
// Verify the user owns this assignment
const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
where: { id: input.mentorAssignmentId },
select: { mentorId: true, projectId: true },
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (assignment.mentorId !== ctx.user.id && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to this mentorship',
})
}
const completion = await ctx.prisma.mentorMilestoneCompletion.create({
data: {
milestoneId: input.milestoneId,
mentorAssignmentId: input.mentorAssignmentId,
completedById: ctx.user.id,
},
})
// Check if all required milestones are now completed
const milestone = await ctx.prisma.mentorMilestone.findUniqueOrThrow({
where: { id: input.milestoneId },
select: { programId: true },
})
const requiredMilestones = await ctx.prisma.mentorMilestone.findMany({
where: { programId: milestone.programId, isRequired: true },
select: { id: true },
})
const completedMilestones = await ctx.prisma.mentorMilestoneCompletion.findMany({
where: {
mentorAssignmentId: input.mentorAssignmentId,
milestoneId: { in: requiredMilestones.map((m) => m.id) },
},
select: { milestoneId: true },
})
const allRequiredDone = requiredMilestones.length > 0 &&
completedMilestones.length >= requiredMilestones.length
if (allRequiredDone) {
await ctx.prisma.mentorAssignment.update({
where: { id: input.mentorAssignmentId },
data: { completionStatus: 'completed' },
})
}
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'COMPLETE_MILESTONE',
entityType: 'MentorMilestoneCompletion',
entityId: completion.id,
detailsJson: {
milestoneId: input.milestoneId,
mentorAssignmentId: input.mentorAssignmentId,
allRequiredDone,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Audit log errors should never break the operation
}
return { completion, allRequiredDone }
}),
/**
* Uncomplete a milestone for an assignment
*/
uncompleteMilestone: mentorProcedure
.input(
z.object({
milestoneId: z.string(),
mentorAssignmentId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
where: { id: input.mentorAssignmentId },
select: { mentorId: true },
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (assignment.mentorId !== ctx.user.id && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to this mentorship',
})
}
await ctx.prisma.mentorMilestoneCompletion.delete({
where: {
milestoneId_mentorAssignmentId: {
milestoneId: input.milestoneId,
mentorAssignmentId: input.mentorAssignmentId,
},
},
})
// Revert completion status if it was completed
await ctx.prisma.mentorAssignment.update({
where: { id: input.mentorAssignmentId },
data: { completionStatus: 'in_progress' },
})
return { success: true }
}),
// =========================================================================
// Admin Milestone Management (F8)
// =========================================================================
/**
* Create a milestone for a program
*/
createMilestone: adminProcedure
.input(
z.object({
programId: z.string(),
name: z.string().min(1).max(255),
description: z.string().max(2000).optional(),
isRequired: z.boolean().default(false),
deadlineOffsetDays: z.number().int().optional().nullable(),
sortOrder: z.number().int().default(0),
})
)
.mutation(async ({ ctx, input }) => {
return ctx.prisma.mentorMilestone.create({
data: input,
})
}),
/**
* Update a milestone
*/
updateMilestone: adminProcedure
.input(
z.object({
milestoneId: z.string(),
name: z.string().min(1).max(255).optional(),
description: z.string().max(2000).optional().nullable(),
isRequired: z.boolean().optional(),
deadlineOffsetDays: z.number().int().optional().nullable(),
sortOrder: z.number().int().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { milestoneId, ...data } = input
return ctx.prisma.mentorMilestone.update({
where: { id: milestoneId },
data,
})
}),
/**
* Delete a milestone (cascades completions)
*/
deleteMilestone: adminProcedure
.input(z.object({ milestoneId: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.mentorMilestone.delete({
where: { id: input.milestoneId },
})
}),
/**
* Reorder milestones
*/
reorderMilestones: adminProcedure
.input(
z.object({
milestoneIds: z.array(z.string()),
})
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.$transaction(
input.milestoneIds.map((id, index) =>
ctx.prisma.mentorMilestone.update({
where: { id },
data: { sortOrder: index },
})
)
)
return { success: true }
}),
// =========================================================================
// Activity Tracking (F8)
// =========================================================================
/**
* Track a mentor's view of an assignment
*/
trackView: mentorProcedure
.input(z.object({ mentorAssignmentId: z.string() }))
.mutation(async ({ ctx, input }) => {
const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
where: { id: input.mentorAssignmentId },
select: { mentorId: true },
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (assignment.mentorId !== ctx.user.id && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to this mentorship',
})
}
return ctx.prisma.mentorAssignment.update({
where: { id: input.mentorAssignmentId },
data: { lastViewedAt: new Date() },
})
}),
/**
* Get activity stats for all mentors (admin)
*/
getActivityStats: adminProcedure
.input(
z.object({
roundId: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const where = input.roundId
? { project: { roundId: input.roundId } }
: {}
const assignments = await ctx.prisma.mentorAssignment.findMany({
where,
include: {
mentor: { select: { id: true, name: true, email: true } },
project: { select: { id: true, title: true } },
notes: { select: { id: true } },
milestoneCompletions: { select: { id: true } },
},
})
// Get message counts per mentor
const mentorIds = [...new Set(assignments.map((a) => a.mentorId))]
const messageCounts = await ctx.prisma.mentorMessage.groupBy({
by: ['senderId'],
where: { senderId: { in: mentorIds } },
_count: true,
})
const messageCountMap = new Map(messageCounts.map((m) => [m.senderId, m._count]))
// Build per-mentor stats
const mentorStats = new Map<string, {
mentor: { id: string; name: string | null; email: string }
assignments: number
lastViewedAt: Date | null
notesCount: number
milestonesCompleted: number
messagesSent: number
completionStatuses: string[]
}>()
for (const assignment of assignments) {
const existing = mentorStats.get(assignment.mentorId)
if (existing) {
existing.assignments++
existing.notesCount += assignment.notes.length
existing.milestonesCompleted += assignment.milestoneCompletions.length
existing.completionStatuses.push(assignment.completionStatus)
if (assignment.lastViewedAt && (!existing.lastViewedAt || assignment.lastViewedAt > existing.lastViewedAt)) {
existing.lastViewedAt = assignment.lastViewedAt
}
} else {
mentorStats.set(assignment.mentorId, {
mentor: assignment.mentor,
assignments: 1,
lastViewedAt: assignment.lastViewedAt,
notesCount: assignment.notes.length,
milestonesCompleted: assignment.milestoneCompletions.length,
messagesSent: messageCountMap.get(assignment.mentorId) || 0,
completionStatuses: [assignment.completionStatus],
})
}
}
return Array.from(mentorStats.values())
}),
})

View File

@@ -0,0 +1,406 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
import { sendStyledNotificationEmail } from '@/lib/email'
export const messageRouter = router({
/**
* Send a message to recipients.
* Resolves recipient list based on recipientType and delivers via specified channels.
*/
send: adminProcedure
.input(
z.object({
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'PROGRAM_TEAM', 'ALL']),
recipientFilter: z.any().optional(),
roundId: z.string().optional(),
subject: z.string().min(1).max(500),
body: z.string().min(1),
deliveryChannels: z.array(z.string()).min(1),
scheduledAt: z.string().datetime().optional(),
templateId: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Resolve recipients based on type
const recipientUserIds = await resolveRecipients(
ctx.prisma,
input.recipientType,
input.recipientFilter,
input.roundId
)
if (recipientUserIds.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No recipients found for the given criteria',
})
}
const isScheduled = !!input.scheduledAt
const now = new Date()
// Create message
const message = await ctx.prisma.message.create({
data: {
senderId: ctx.user.id,
recipientType: input.recipientType,
recipientFilter: input.recipientFilter ?? undefined,
roundId: input.roundId,
templateId: input.templateId,
subject: input.subject,
body: input.body,
deliveryChannels: input.deliveryChannels,
scheduledAt: input.scheduledAt ? new Date(input.scheduledAt) : undefined,
sentAt: isScheduled ? undefined : now,
recipients: {
create: recipientUserIds.flatMap((userId) =>
input.deliveryChannels.map((channel) => ({
userId,
channel,
}))
),
},
},
include: {
recipients: true,
},
})
// If not scheduled, deliver immediately for EMAIL channel
if (!isScheduled && input.deliveryChannels.includes('EMAIL')) {
const users = await ctx.prisma.user.findMany({
where: { id: { in: recipientUserIds } },
select: { id: true, name: true, email: true },
})
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
for (const user of users) {
try {
await sendStyledNotificationEmail(
user.email,
user.name || '',
'MESSAGE',
{
name: user.name || undefined,
title: input.subject,
message: input.body,
linkUrl: `${baseUrl}/messages`,
}
)
} catch (error) {
console.error(`[Message] Failed to send email to ${user.email}:`, error)
}
}
}
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'SEND_MESSAGE',
entityType: 'Message',
entityId: message.id,
detailsJson: {
recipientType: input.recipientType,
recipientCount: recipientUserIds.length,
channels: input.deliveryChannels,
scheduled: isScheduled,
},
})
} catch {}
return {
...message,
recipientCount: recipientUserIds.length,
}
}),
/**
* Get the current user's inbox (messages sent to them).
*/
inbox: protectedProcedure
.input(
z.object({
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(20),
}).optional()
)
.query(async ({ ctx, input }) => {
const page = input?.page ?? 1
const pageSize = input?.pageSize ?? 20
const skip = (page - 1) * pageSize
const [items, total] = await Promise.all([
ctx.prisma.messageRecipient.findMany({
where: { userId: ctx.user.id },
include: {
message: {
include: {
sender: {
select: { id: true, name: true, email: true },
},
},
},
},
orderBy: { message: { createdAt: 'desc' } },
skip,
take: pageSize,
}),
ctx.prisma.messageRecipient.count({
where: { userId: ctx.user.id },
}),
])
return {
items,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
}
}),
/**
* Mark a message as read.
*/
markRead: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const recipient = await ctx.prisma.messageRecipient.findUnique({
where: { id: input.id },
})
if (!recipient || recipient.userId !== ctx.user.id) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Message not found',
})
}
return ctx.prisma.messageRecipient.update({
where: { id: input.id },
data: {
isRead: true,
readAt: new Date(),
},
})
}),
/**
* Get unread message count for the current user.
*/
getUnreadCount: protectedProcedure.query(async ({ ctx }) => {
const count = await ctx.prisma.messageRecipient.count({
where: {
userId: ctx.user.id,
isRead: false,
},
})
return { count }
}),
// =========================================================================
// Template procedures
// =========================================================================
/**
* List all message templates.
*/
listTemplates: adminProcedure
.input(
z.object({
category: z.string().optional(),
activeOnly: z.boolean().default(true),
}).optional()
)
.query(async ({ ctx, input }) => {
return ctx.prisma.messageTemplate.findMany({
where: {
...(input?.category ? { category: input.category } : {}),
...(input?.activeOnly !== false ? { isActive: true } : {}),
},
orderBy: { createdAt: 'desc' },
})
}),
/**
* Create a message template.
*/
createTemplate: adminProcedure
.input(
z.object({
name: z.string().min(1).max(200),
category: z.string().min(1).max(100),
subject: z.string().min(1).max(500),
body: z.string().min(1),
variables: z.any().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const template = await ctx.prisma.messageTemplate.create({
data: {
name: input.name,
category: input.category,
subject: input.subject,
body: input.body,
variables: input.variables ?? undefined,
createdBy: ctx.user.id,
},
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE_MESSAGE_TEMPLATE',
entityType: 'MessageTemplate',
entityId: template.id,
detailsJson: { name: input.name, category: input.category },
})
} catch {}
return template
}),
/**
* Update a message template.
*/
updateTemplate: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).max(200).optional(),
category: z.string().min(1).max(100).optional(),
subject: z.string().min(1).max(500).optional(),
body: z.string().min(1).optional(),
variables: z.any().optional(),
isActive: z.boolean().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
const template = await ctx.prisma.messageTemplate.update({
where: { id },
data: {
...(data.name !== undefined ? { name: data.name } : {}),
...(data.category !== undefined ? { category: data.category } : {}),
...(data.subject !== undefined ? { subject: data.subject } : {}),
...(data.body !== undefined ? { body: data.body } : {}),
...(data.variables !== undefined ? { variables: data.variables } : {}),
...(data.isActive !== undefined ? { isActive: data.isActive } : {}),
},
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_MESSAGE_TEMPLATE',
entityType: 'MessageTemplate',
entityId: id,
detailsJson: { updatedFields: Object.keys(data) },
})
} catch {}
return template
}),
/**
* Soft-delete a message template (set isActive=false).
*/
deleteTemplate: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const template = await ctx.prisma.messageTemplate.update({
where: { id: input.id },
data: { isActive: false },
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE_MESSAGE_TEMPLATE',
entityType: 'MessageTemplate',
entityId: input.id,
})
} catch {}
return template
}),
})
// =============================================================================
// Helper: Resolve recipient user IDs based on recipientType
// =============================================================================
type PrismaClient = Parameters<Parameters<typeof adminProcedure.mutation>[0]>[0]['ctx']['prisma']
async function resolveRecipients(
prisma: PrismaClient,
recipientType: string,
recipientFilter: unknown,
roundId?: string
): Promise<string[]> {
const filter = recipientFilter as Record<string, unknown> | undefined
switch (recipientType) {
case 'USER': {
const userId = filter?.userId as string
if (!userId) return []
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true },
})
return user ? [user.id] : []
}
case 'ROLE': {
const role = filter?.role as string
if (!role) return []
const users = await prisma.user.findMany({
where: { role: role as any, status: 'ACTIVE' },
select: { id: true },
})
return users.map((u) => u.id)
}
case 'ROUND_JURY': {
const targetRoundId = roundId || (filter?.roundId as string)
if (!targetRoundId) return []
const assignments = await prisma.assignment.findMany({
where: { roundId: targetRoundId },
select: { userId: true },
distinct: ['userId'],
})
return assignments.map((a) => a.userId)
}
case 'PROGRAM_TEAM': {
const programId = filter?.programId as string
if (!programId) return []
// Get all applicants with projects in rounds of this program
const projects = await prisma.project.findMany({
where: { round: { programId } },
select: { submittedByUserId: true },
})
const ids = new Set(projects.map((p) => p.submittedByUserId).filter(Boolean) as string[])
return [...ids]
}
case 'ALL': {
const users = await prisma.user.findMany({
where: { status: 'ACTIVE' },
select: { id: true },
})
return users.map((u) => u.id)
}
default:
return []
}
}

View File

@@ -26,6 +26,22 @@ export const roundRouter = router({
})
}),
/**
* List all rounds across all programs (admin only, for messaging/filtering)
*/
listAll: adminProcedure
.query(async ({ ctx }) => {
return ctx.prisma.round.findMany({
orderBy: [{ program: { name: 'asc' } }, { sortOrder: 'asc' }],
select: {
id: true,
name: true,
programId: true,
program: { select: { name: true } },
},
})
}),
/**
* Get a single round with stats
*/

View File

@@ -0,0 +1,209 @@
import { z } from 'zod'
import { RoundType } from '@prisma/client'
import { router, adminProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
export const roundTemplateRouter = router({
/**
* List all round templates, optionally filtered by programId.
*/
list: adminProcedure
.input(
z.object({
programId: z.string().optional(),
}).optional()
)
.query(async ({ ctx, input }) => {
return ctx.prisma.roundTemplate.findMany({
where: {
...(input?.programId ? { programId: input.programId } : {}),
},
orderBy: { createdAt: 'desc' },
})
}),
/**
* Get a single template by ID.
*/
getById: adminProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const template = await ctx.prisma.roundTemplate.findUnique({
where: { id: input.id },
})
if (!template) {
throw new Error('Template not found')
}
return template
}),
/**
* Create a new round template from scratch.
*/
create: adminProcedure
.input(
z.object({
name: z.string().min(1).max(200),
description: z.string().optional(),
programId: z.string().optional(),
roundType: z.nativeEnum(RoundType).default('EVALUATION'),
criteriaJson: z.any(),
settingsJson: z.any().optional(),
assignmentConfig: z.any().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const template = await ctx.prisma.roundTemplate.create({
data: {
name: input.name,
description: input.description,
programId: input.programId,
roundType: input.roundType,
criteriaJson: input.criteriaJson,
settingsJson: input.settingsJson ?? undefined,
assignmentConfig: input.assignmentConfig ?? undefined,
createdBy: ctx.user.id,
},
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE_ROUND_TEMPLATE',
entityType: 'RoundTemplate',
entityId: template.id,
detailsJson: { name: input.name },
})
} catch {}
return template
}),
/**
* Create a template from an existing round (snapshot).
*/
createFromRound: adminProcedure
.input(
z.object({
roundId: z.string(),
name: z.string().min(1).max(200),
description: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Fetch the round and its active evaluation form
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
include: {
evaluationForms: {
where: { isActive: true },
take: 1,
},
},
})
if (!round) {
throw new Error('Round not found')
}
const form = round.evaluationForms[0]
const criteriaJson = form?.criteriaJson ?? []
const template = await ctx.prisma.roundTemplate.create({
data: {
name: input.name,
description: input.description || `Snapshot of ${round.name}`,
programId: round.programId,
roundType: round.roundType,
criteriaJson,
settingsJson: round.settingsJson ?? undefined,
createdBy: ctx.user.id,
},
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE_ROUND_TEMPLATE_FROM_ROUND',
entityType: 'RoundTemplate',
entityId: template.id,
detailsJson: { name: input.name, sourceRoundId: input.roundId },
})
} catch {}
return template
}),
/**
* Update a template.
*/
update: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).max(200).optional(),
description: z.string().optional(),
programId: z.string().nullable().optional(),
roundType: z.nativeEnum(RoundType).optional(),
criteriaJson: z.any().optional(),
settingsJson: z.any().optional(),
assignmentConfig: z.any().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
const template = await ctx.prisma.roundTemplate.update({
where: { id },
data: {
...(data.name !== undefined ? { name: data.name } : {}),
...(data.description !== undefined ? { description: data.description } : {}),
...(data.programId !== undefined ? { programId: data.programId } : {}),
...(data.roundType !== undefined ? { roundType: data.roundType } : {}),
...(data.criteriaJson !== undefined ? { criteriaJson: data.criteriaJson } : {}),
...(data.settingsJson !== undefined ? { settingsJson: data.settingsJson } : {}),
...(data.assignmentConfig !== undefined ? { assignmentConfig: data.assignmentConfig } : {}),
},
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_ROUND_TEMPLATE',
entityType: 'RoundTemplate',
entityId: id,
detailsJson: { updatedFields: Object.keys(data) },
})
} catch {}
return template
}),
/**
* Delete a template.
*/
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.roundTemplate.delete({
where: { id: input.id },
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE_ROUND_TEMPLATE',
entityType: 'RoundTemplate',
entityId: input.id,
})
} catch {}
return { success: true }
}),
})

View File

@@ -26,14 +26,22 @@ export const settingsRouter = router({
* These are non-sensitive settings that can be exposed to any user
*/
getFeatureFlags: protectedProcedure.query(async ({ ctx }) => {
const [whatsappEnabled] = await Promise.all([
const [whatsappEnabled, defaultLocale, availableLocales] = await Promise.all([
ctx.prisma.systemSettings.findUnique({
where: { key: 'whatsapp_enabled' },
}),
ctx.prisma.systemSettings.findUnique({
where: { key: 'i18n_default_locale' },
}),
ctx.prisma.systemSettings.findUnique({
where: { key: 'i18n_available_locales' },
}),
])
return {
whatsappEnabled: whatsappEnabled?.value === 'true',
defaultLocale: defaultLocale?.value || 'en',
availableLocales: availableLocales?.value ? JSON.parse(availableLocales.value) : ['en', 'fr'],
}
}),
@@ -159,13 +167,14 @@ export const settingsRouter = router({
)
.mutation(async ({ ctx, input }) => {
// Infer category from key prefix if not provided
const inferCategory = (key: string): 'AI' | 'BRANDING' | 'EMAIL' | 'STORAGE' | 'SECURITY' | 'DEFAULTS' | 'WHATSAPP' => {
const inferCategory = (key: string): 'AI' | 'BRANDING' | 'EMAIL' | 'STORAGE' | 'SECURITY' | 'DEFAULTS' | 'WHATSAPP' | 'LOCALIZATION' => {
if (key.startsWith('openai') || key.startsWith('ai_')) return 'AI'
if (key.startsWith('smtp_') || key.startsWith('email_')) return 'EMAIL'
if (key.startsWith('storage_') || key.startsWith('local_storage') || key.startsWith('max_file') || key.startsWith('avatar_') || key.startsWith('allowed_file')) return 'STORAGE'
if (key.startsWith('brand_') || key.startsWith('logo_') || key.startsWith('primary_') || key.startsWith('theme_')) return 'BRANDING'
if (key.startsWith('whatsapp_')) return 'WHATSAPP'
if (key.startsWith('security_') || key.startsWith('session_')) return 'SECURITY'
if (key.startsWith('i18n_') || key.startsWith('locale_')) return 'LOCALIZATION'
return 'DEFAULTS'
}
@@ -529,4 +538,245 @@ export const settingsRouter = router({
costFormatted: formatCost(day.cost),
}))
}),
// =========================================================================
// Feature-specific settings categories
// =========================================================================
/**
* Get digest notification settings
*/
getDigestSettings: adminProcedure.query(async ({ ctx }) => {
const settings = await ctx.prisma.systemSettings.findMany({
where: { category: 'DIGEST' },
orderBy: { key: 'asc' },
})
return settings
}),
/**
* Update digest notification settings
*/
updateDigestSettings: superAdminProcedure
.input(
z.object({
settings: z.array(
z.object({
key: z.string(),
value: z.string(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
const results = await Promise.all(
input.settings.map((s) =>
ctx.prisma.systemSettings.upsert({
where: { key: s.key },
update: { value: s.value, updatedBy: ctx.user.id },
create: {
key: s.key,
value: s.value,
category: 'DIGEST',
updatedBy: ctx.user.id,
},
})
)
)
// Audit log
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_DIGEST_SETTINGS',
entityType: 'SystemSettings',
detailsJson: { keys: input.settings.map((s) => s.key) },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Never throw on audit failure
}
return results
}),
/**
* Get analytics/reporting settings
*/
getAnalyticsSettings: adminProcedure.query(async ({ ctx }) => {
const settings = await ctx.prisma.systemSettings.findMany({
where: { category: 'ANALYTICS' },
orderBy: { key: 'asc' },
})
return settings
}),
/**
* Update analytics/reporting settings
*/
updateAnalyticsSettings: superAdminProcedure
.input(
z.object({
settings: z.array(
z.object({
key: z.string(),
value: z.string(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
const results = await Promise.all(
input.settings.map((s) =>
ctx.prisma.systemSettings.upsert({
where: { key: s.key },
update: { value: s.value, updatedBy: ctx.user.id },
create: {
key: s.key,
value: s.value,
category: 'ANALYTICS',
updatedBy: ctx.user.id,
},
})
)
)
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_ANALYTICS_SETTINGS',
entityType: 'SystemSettings',
detailsJson: { keys: input.settings.map((s) => s.key) },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Never throw on audit failure
}
return results
}),
/**
* Get audit configuration settings
*/
getAuditSettings: adminProcedure.query(async ({ ctx }) => {
const settings = await ctx.prisma.systemSettings.findMany({
where: { category: 'AUDIT_CONFIG' },
orderBy: { key: 'asc' },
})
return settings
}),
/**
* Update audit configuration settings
*/
updateAuditSettings: superAdminProcedure
.input(
z.object({
settings: z.array(
z.object({
key: z.string(),
value: z.string(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
const results = await Promise.all(
input.settings.map((s) =>
ctx.prisma.systemSettings.upsert({
where: { key: s.key },
update: { value: s.value, updatedBy: ctx.user.id },
create: {
key: s.key,
value: s.value,
category: 'AUDIT_CONFIG',
updatedBy: ctx.user.id,
},
})
)
)
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_AUDIT_SETTINGS',
entityType: 'SystemSettings',
detailsJson: { keys: input.settings.map((s) => s.key) },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Never throw on audit failure
}
return results
}),
/**
* Get localization settings
*/
getLocalizationSettings: adminProcedure.query(async ({ ctx }) => {
const settings = await ctx.prisma.systemSettings.findMany({
where: { category: 'LOCALIZATION' },
orderBy: { key: 'asc' },
})
return settings
}),
/**
* Update localization settings
*/
updateLocalizationSettings: superAdminProcedure
.input(
z.object({
settings: z.array(
z.object({
key: z.string(),
value: z.string(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
const results = await Promise.all(
input.settings.map((s) =>
ctx.prisma.systemSettings.upsert({
where: { key: s.key },
update: { value: s.value, updatedBy: ctx.user.id },
create: {
key: s.key,
value: s.value,
category: 'LOCALIZATION',
updatedBy: ctx.user.id,
},
})
)
)
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_LOCALIZATION_SETTINGS',
entityType: 'SystemSettings',
detailsJson: { keys: input.settings.map((s) => s.key) },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Never throw on audit failure
}
return results
}),
})

View File

@@ -34,6 +34,9 @@ export const userRouter = router({
bio: true,
notificationPreference: true,
profileImageKey: true,
digestFrequency: true,
availabilityJson: true,
preferredWorkload: true,
createdAt: true,
lastLoginAt: true,
},
@@ -80,10 +83,13 @@ export const userRouter = router({
phoneNumber: z.string().max(20).optional().nullable(),
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
expertiseTags: z.array(z.string()).max(15).optional(),
digestFrequency: z.enum(['none', 'daily', 'weekly']).optional(),
availabilityJson: z.any().optional(),
preferredWorkload: z.number().int().min(1).max(100).optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const { bio, expertiseTags, ...directFields } = input
const { bio, expertiseTags, availabilityJson, preferredWorkload, digestFrequency, ...directFields } = input
// If bio is provided, merge it into metadataJson
let metadataJson: Prisma.InputJsonValue | undefined
@@ -102,6 +108,9 @@ export const userRouter = router({
...directFields,
...(metadataJson !== undefined && { metadataJson }),
...(expertiseTags !== undefined && { expertiseTags }),
...(digestFrequency !== undefined && { digestFrequency }),
...(availabilityJson !== undefined && { availabilityJson: availabilityJson as Prisma.InputJsonValue }),
...(preferredWorkload !== undefined && { preferredWorkload }),
},
})
}),
@@ -215,6 +224,8 @@ export const userRouter = router({
status: true,
expertiseTags: true,
maxAssignments: true,
availabilityJson: true,
preferredWorkload: true,
profileImageKey: true,
profileImageProvider: true,
createdAt: true,
@@ -326,6 +337,8 @@ export const userRouter = router({
status: z.enum(['INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
expertiseTags: z.array(z.string()).optional(),
maxAssignments: z.number().int().min(1).max(100).optional().nullable(),
availabilityJson: z.any().optional(),
preferredWorkload: z.number().int().min(1).max(100).optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
@@ -630,6 +643,8 @@ export const userRouter = router({
name: true,
expertiseTags: true,
maxAssignments: true,
availabilityJson: true,
preferredWorkload: true,
profileImageKey: true,
profileImageProvider: true,
_count: {
@@ -1063,4 +1078,30 @@ export const userRouter = router({
return { success: true, message: 'If an account exists with this email, a password reset link will be sent.' }
}),
/**
* Get current user's digest settings along with global digest config
*/
getDigestSettings: protectedProcedure.query(async ({ ctx }) => {
const [user, digestEnabled, digestSections] = await Promise.all([
ctx.prisma.user.findUniqueOrThrow({
where: { id: ctx.user.id },
select: { digestFrequency: true },
}),
ctx.prisma.systemSettings.findUnique({
where: { key: 'digest_enabled' },
select: { value: true },
}),
ctx.prisma.systemSettings.findUnique({
where: { key: 'digest_sections' },
select: { value: true },
}),
])
return {
digestFrequency: user.digestFrequency,
globalDigestEnabled: digestEnabled?.value === 'true',
globalDigestSections: digestSections?.value ? JSON.parse(digestSections.value) : [],
}
}),
})

View File

@@ -0,0 +1,304 @@
import { z } from 'zod'
import { router, superAdminProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
import {
generateWebhookSecret,
deliverWebhook,
} from '@/server/services/webhook-dispatcher'
export const WEBHOOK_EVENTS = [
'evaluation.submitted',
'evaluation.updated',
'project.created',
'project.statusChanged',
'round.activated',
'round.closed',
'assignment.created',
'assignment.completed',
'user.invited',
'user.activated',
] as const
export const webhookRouter = router({
/**
* List all webhooks with delivery stats.
*/
list: superAdminProcedure.query(async ({ ctx }) => {
const webhooks = await ctx.prisma.webhook.findMany({
include: {
_count: {
select: { deliveries: true },
},
createdBy: {
select: { id: true, name: true, email: true },
},
},
orderBy: { createdAt: 'desc' },
})
// Compute recent delivery stats for each webhook
const now = new Date()
const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
const stats = await Promise.all(
webhooks.map(async (wh) => {
const [delivered, failed] = await Promise.all([
ctx.prisma.webhookDelivery.count({
where: {
webhookId: wh.id,
status: 'DELIVERED',
createdAt: { gte: twentyFourHoursAgo },
},
}),
ctx.prisma.webhookDelivery.count({
where: {
webhookId: wh.id,
status: 'FAILED',
createdAt: { gte: twentyFourHoursAgo },
},
}),
])
return {
...wh,
recentDelivered: delivered,
recentFailed: failed,
}
})
)
return stats
}),
/**
* Create a new webhook.
*/
create: superAdminProcedure
.input(
z.object({
name: z.string().min(1).max(200),
url: z.string().url(),
events: z.array(z.string()).min(1),
headers: z.any().optional(),
maxRetries: z.number().int().min(0).max(10).default(3),
})
)
.mutation(async ({ ctx, input }) => {
const secret = generateWebhookSecret()
const webhook = await ctx.prisma.webhook.create({
data: {
name: input.name,
url: input.url,
secret,
events: input.events,
headers: input.headers ?? undefined,
maxRetries: input.maxRetries,
createdById: ctx.user.id,
},
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE_WEBHOOK',
entityType: 'Webhook',
entityId: webhook.id,
detailsJson: { name: input.name, url: input.url, events: input.events },
})
} catch {}
return webhook
}),
/**
* Update a webhook.
*/
update: superAdminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).max(200).optional(),
url: z.string().url().optional(),
events: z.array(z.string()).min(1).optional(),
headers: z.any().optional(),
isActive: z.boolean().optional(),
maxRetries: z.number().int().min(0).max(10).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
const webhook = await ctx.prisma.webhook.update({
where: { id },
data: {
...(data.name !== undefined ? { name: data.name } : {}),
...(data.url !== undefined ? { url: data.url } : {}),
...(data.events !== undefined ? { events: data.events } : {}),
...(data.headers !== undefined ? { headers: data.headers } : {}),
...(data.isActive !== undefined ? { isActive: data.isActive } : {}),
...(data.maxRetries !== undefined ? { maxRetries: data.maxRetries } : {}),
},
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_WEBHOOK',
entityType: 'Webhook',
entityId: id,
detailsJson: { updatedFields: Object.keys(data) },
})
} catch {}
return webhook
}),
/**
* Delete a webhook and its delivery history.
*/
delete: superAdminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
// Cascade delete is defined in schema, so just delete the webhook
await ctx.prisma.webhook.delete({
where: { id: input.id },
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE_WEBHOOK',
entityType: 'Webhook',
entityId: input.id,
})
} catch {}
return { success: true }
}),
/**
* Send a test payload to a webhook.
*/
test: superAdminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const webhook = await ctx.prisma.webhook.findUnique({
where: { id: input.id },
})
if (!webhook) {
throw new Error('Webhook not found')
}
const testPayload = {
event: 'test',
timestamp: new Date().toISOString(),
data: {
message: 'This is a test webhook delivery from MOPC Platform.',
webhookId: webhook.id,
webhookName: webhook.name,
},
}
const delivery = await ctx.prisma.webhookDelivery.create({
data: {
webhookId: webhook.id,
event: 'test',
payload: testPayload,
status: 'PENDING',
attempts: 0,
},
})
await deliverWebhook(delivery.id)
// Fetch updated delivery to get the result
const result = await ctx.prisma.webhookDelivery.findUnique({
where: { id: delivery.id },
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'TEST_WEBHOOK',
entityType: 'Webhook',
entityId: input.id,
detailsJson: { deliveryStatus: result?.status },
})
} catch {}
return result
}),
/**
* Get paginated delivery log for a webhook.
*/
getDeliveryLog: superAdminProcedure
.input(
z.object({
webhookId: z.string(),
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(20),
})
)
.query(async ({ ctx, input }) => {
const skip = (input.page - 1) * input.pageSize
const [items, total] = await Promise.all([
ctx.prisma.webhookDelivery.findMany({
where: { webhookId: input.webhookId },
orderBy: { createdAt: 'desc' },
skip,
take: input.pageSize,
}),
ctx.prisma.webhookDelivery.count({
where: { webhookId: input.webhookId },
}),
])
return {
items,
total,
page: input.page,
pageSize: input.pageSize,
totalPages: Math.ceil(total / input.pageSize),
}
}),
/**
* Regenerate the HMAC secret for a webhook.
*/
regenerateSecret: superAdminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const newSecret = generateWebhookSecret()
const webhook = await ctx.prisma.webhook.update({
where: { id: input.id },
data: { secret: newSecret },
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'REGENERATE_WEBHOOK_SECRET',
entityType: 'Webhook',
entityId: input.id,
})
} catch {}
return webhook
}),
/**
* Get available webhook events.
*/
getAvailableEvents: superAdminProcedure.query(() => {
return WEBHOOK_EVENTS
}),
})