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