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

@@ -26,14 +26,22 @@ export const settingsRouter = router({
* These are non-sensitive settings that can be exposed to any user
*/
getFeatureFlags: protectedProcedure.query(async ({ ctx }) => {
const [whatsappEnabled] = await Promise.all([
const [whatsappEnabled, defaultLocale, availableLocales] = await Promise.all([
ctx.prisma.systemSettings.findUnique({
where: { key: 'whatsapp_enabled' },
}),
ctx.prisma.systemSettings.findUnique({
where: { key: 'i18n_default_locale' },
}),
ctx.prisma.systemSettings.findUnique({
where: { key: 'i18n_available_locales' },
}),
])
return {
whatsappEnabled: whatsappEnabled?.value === 'true',
defaultLocale: defaultLocale?.value || 'en',
availableLocales: availableLocales?.value ? JSON.parse(availableLocales.value) : ['en', 'fr'],
}
}),
@@ -159,13 +167,14 @@ export const settingsRouter = router({
)
.mutation(async ({ ctx, input }) => {
// Infer category from key prefix if not provided
const inferCategory = (key: string): 'AI' | 'BRANDING' | 'EMAIL' | 'STORAGE' | 'SECURITY' | 'DEFAULTS' | 'WHATSAPP' => {
const inferCategory = (key: string): 'AI' | 'BRANDING' | 'EMAIL' | 'STORAGE' | 'SECURITY' | 'DEFAULTS' | 'WHATSAPP' | 'LOCALIZATION' => {
if (key.startsWith('openai') || key.startsWith('ai_')) return 'AI'
if (key.startsWith('smtp_') || key.startsWith('email_')) return 'EMAIL'
if (key.startsWith('storage_') || key.startsWith('local_storage') || key.startsWith('max_file') || key.startsWith('avatar_') || key.startsWith('allowed_file')) return 'STORAGE'
if (key.startsWith('brand_') || key.startsWith('logo_') || key.startsWith('primary_') || key.startsWith('theme_')) return 'BRANDING'
if (key.startsWith('whatsapp_')) return 'WHATSAPP'
if (key.startsWith('security_') || key.startsWith('session_')) return 'SECURITY'
if (key.startsWith('i18n_') || key.startsWith('locale_')) return 'LOCALIZATION'
return 'DEFAULTS'
}
@@ -529,4 +538,245 @@ export const settingsRouter = router({
costFormatted: formatCost(day.cost),
}))
}),
// =========================================================================
// Feature-specific settings categories
// =========================================================================
/**
* Get digest notification settings
*/
getDigestSettings: adminProcedure.query(async ({ ctx }) => {
const settings = await ctx.prisma.systemSettings.findMany({
where: { category: 'DIGEST' },
orderBy: { key: 'asc' },
})
return settings
}),
/**
* Update digest notification settings
*/
updateDigestSettings: superAdminProcedure
.input(
z.object({
settings: z.array(
z.object({
key: z.string(),
value: z.string(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
const results = await Promise.all(
input.settings.map((s) =>
ctx.prisma.systemSettings.upsert({
where: { key: s.key },
update: { value: s.value, updatedBy: ctx.user.id },
create: {
key: s.key,
value: s.value,
category: 'DIGEST',
updatedBy: ctx.user.id,
},
})
)
)
// Audit log
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_DIGEST_SETTINGS',
entityType: 'SystemSettings',
detailsJson: { keys: input.settings.map((s) => s.key) },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Never throw on audit failure
}
return results
}),
/**
* Get analytics/reporting settings
*/
getAnalyticsSettings: adminProcedure.query(async ({ ctx }) => {
const settings = await ctx.prisma.systemSettings.findMany({
where: { category: 'ANALYTICS' },
orderBy: { key: 'asc' },
})
return settings
}),
/**
* Update analytics/reporting settings
*/
updateAnalyticsSettings: superAdminProcedure
.input(
z.object({
settings: z.array(
z.object({
key: z.string(),
value: z.string(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
const results = await Promise.all(
input.settings.map((s) =>
ctx.prisma.systemSettings.upsert({
where: { key: s.key },
update: { value: s.value, updatedBy: ctx.user.id },
create: {
key: s.key,
value: s.value,
category: 'ANALYTICS',
updatedBy: ctx.user.id,
},
})
)
)
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_ANALYTICS_SETTINGS',
entityType: 'SystemSettings',
detailsJson: { keys: input.settings.map((s) => s.key) },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Never throw on audit failure
}
return results
}),
/**
* Get audit configuration settings
*/
getAuditSettings: adminProcedure.query(async ({ ctx }) => {
const settings = await ctx.prisma.systemSettings.findMany({
where: { category: 'AUDIT_CONFIG' },
orderBy: { key: 'asc' },
})
return settings
}),
/**
* Update audit configuration settings
*/
updateAuditSettings: superAdminProcedure
.input(
z.object({
settings: z.array(
z.object({
key: z.string(),
value: z.string(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
const results = await Promise.all(
input.settings.map((s) =>
ctx.prisma.systemSettings.upsert({
where: { key: s.key },
update: { value: s.value, updatedBy: ctx.user.id },
create: {
key: s.key,
value: s.value,
category: 'AUDIT_CONFIG',
updatedBy: ctx.user.id,
},
})
)
)
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_AUDIT_SETTINGS',
entityType: 'SystemSettings',
detailsJson: { keys: input.settings.map((s) => s.key) },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Never throw on audit failure
}
return results
}),
/**
* Get localization settings
*/
getLocalizationSettings: adminProcedure.query(async ({ ctx }) => {
const settings = await ctx.prisma.systemSettings.findMany({
where: { category: 'LOCALIZATION' },
orderBy: { key: 'asc' },
})
return settings
}),
/**
* Update localization settings
*/
updateLocalizationSettings: superAdminProcedure
.input(
z.object({
settings: z.array(
z.object({
key: z.string(),
value: z.string(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
const results = await Promise.all(
input.settings.map((s) =>
ctx.prisma.systemSettings.upsert({
where: { key: s.key },
update: { value: s.value, updatedBy: ctx.user.id },
create: {
key: s.key,
value: s.value,
category: 'LOCALIZATION',
updatedBy: ctx.user.id,
},
})
)
)
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_LOCALIZATION_SETTINGS',
entityType: 'SystemSettings',
detailsJson: { keys: input.settings.map((s) => s.key) },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Never throw on audit failure
}
return results
}),
})