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

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

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

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

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

View File

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