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

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