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:
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user