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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
}),
|
||||
})
|
||||
|
||||
406
src/server/routers/message.ts
Normal file
406
src/server/routers/message.ts
Normal 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 []
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
209
src/server/routers/roundTemplate.ts
Normal file
209
src/server/routers/roundTemplate.ts
Normal 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 }
|
||||
}),
|
||||
})
|
||||
@@ -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
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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) : [],
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
304
src/server/routers/webhook.ts
Normal file
304
src/server/routers/webhook.ts
Normal 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
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user