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

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