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:
@@ -20,6 +20,12 @@ import {
|
||||
Bell,
|
||||
Tags,
|
||||
ExternalLink,
|
||||
Newspaper,
|
||||
BarChart3,
|
||||
ShieldAlert,
|
||||
Globe,
|
||||
Webhook,
|
||||
LayoutTemplate,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -112,9 +118,42 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
||||
'autosave_interval_seconds',
|
||||
])
|
||||
|
||||
const digestSettings = getSettingsByKeys([
|
||||
'digest_enabled',
|
||||
'digest_default_frequency',
|
||||
'digest_send_hour',
|
||||
'digest_include_evaluations',
|
||||
'digest_include_assignments',
|
||||
'digest_include_deadlines',
|
||||
'digest_include_announcements',
|
||||
])
|
||||
|
||||
const analyticsSettings = getSettingsByKeys([
|
||||
'analytics_observer_scores_tab',
|
||||
'analytics_observer_progress_tab',
|
||||
'analytics_observer_juror_tab',
|
||||
'analytics_observer_comparison_tab',
|
||||
'analytics_pdf_enabled',
|
||||
'analytics_pdf_sections',
|
||||
])
|
||||
|
||||
const auditSecuritySettings = getSettingsByKeys([
|
||||
'audit_retention_days',
|
||||
'anomaly_detection_enabled',
|
||||
'anomaly_rapid_actions_threshold',
|
||||
'anomaly_off_hours_start',
|
||||
'anomaly_off_hours_end',
|
||||
])
|
||||
|
||||
const localizationSettings = getSettingsByKeys([
|
||||
'localization_enabled_locales',
|
||||
'localization_default_locale',
|
||||
])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs defaultValue="ai" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-4 lg:grid-cols-8">
|
||||
<TabsList className="flex flex-wrap h-auto gap-1">
|
||||
<TabsTrigger value="ai" className="gap-2">
|
||||
<Bot className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">AI</span>
|
||||
@@ -147,6 +186,22 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Defaults</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="digest" className="gap-2">
|
||||
<Newspaper className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Digest</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="analytics" className="gap-2">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Analytics</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="audit" className="gap-2">
|
||||
<ShieldAlert className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Audit</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="localization" className="gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Locale</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="ai" className="space-y-6">
|
||||
@@ -279,8 +334,456 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="digest" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Digest Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
Configure automated digest emails sent to users
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DigestSettingsSection settings={digestSettings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="analytics" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Analytics & Reports</CardTitle>
|
||||
<CardDescription>
|
||||
Configure observer dashboard visibility and PDF report settings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AnalyticsSettingsSection settings={analyticsSettings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="audit" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Audit & Security</CardTitle>
|
||||
<CardDescription>
|
||||
Configure audit log retention and anomaly detection
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AuditSettingsSection settings={auditSecuritySettings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="localization" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Localization</CardTitle>
|
||||
<CardDescription>
|
||||
Configure language and locale settings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LocalizationSettingsSection settings={localizationSettings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Quick Links to sub-pages */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<LayoutTemplate className="h-4 w-4" />
|
||||
Round Templates
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Create reusable round configuration templates
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link href="/admin/settings/templates">
|
||||
<LayoutTemplate className="mr-2 h-4 w-4" />
|
||||
Manage Templates
|
||||
<ExternalLink className="ml-2 h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Webhook className="h-4 w-4" />
|
||||
Webhooks
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure webhook endpoints for platform events
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link href="/admin/settings/webhooks">
|
||||
<Webhook className="mr-2 h-4 w-4" />
|
||||
Manage Webhooks
|
||||
<ExternalLink className="ml-2 h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export { SettingsSkeleton }
|
||||
|
||||
// Inline settings sections for new tabs
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
function useSettingsMutation() {
|
||||
const utils = trpc.useUtils()
|
||||
return trpc.settings.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.settings.invalidate()
|
||||
toast.success('Setting updated')
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
}
|
||||
|
||||
function SettingToggle({
|
||||
label,
|
||||
description,
|
||||
settingKey,
|
||||
value,
|
||||
}: {
|
||||
label: string
|
||||
description?: string
|
||||
settingKey: string
|
||||
value: string
|
||||
}) {
|
||||
const mutation = useSettingsMutation()
|
||||
const isChecked = value === 'true'
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">{label}</Label>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={isChecked}
|
||||
disabled={mutation.isPending}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ key: settingKey, value: String(checked) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingInput({
|
||||
label,
|
||||
description,
|
||||
settingKey,
|
||||
value,
|
||||
type = 'text',
|
||||
}: {
|
||||
label: string
|
||||
description?: string
|
||||
settingKey: string
|
||||
value: string
|
||||
type?: string
|
||||
}) {
|
||||
const [localValue, setLocalValue] = useState(value)
|
||||
const mutation = useSettingsMutation()
|
||||
|
||||
const save = () => {
|
||||
if (localValue !== value) {
|
||||
mutation.mutate({ key: settingKey, value: localValue })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">{label}</Label>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type={type}
|
||||
value={localValue}
|
||||
onChange={(e) => setLocalValue(e.target.value)}
|
||||
onBlur={save}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
{mutation.isPending && <Loader2 className="h-4 w-4 animate-spin self-center" />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingSelect({
|
||||
label,
|
||||
description,
|
||||
settingKey,
|
||||
value,
|
||||
options,
|
||||
}: {
|
||||
label: string
|
||||
description?: string
|
||||
settingKey: string
|
||||
value: string
|
||||
options: Array<{ value: string; label: string }>
|
||||
}) {
|
||||
const mutation = useSettingsMutation()
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">{label}</Label>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
<Select
|
||||
value={value || options[0]?.value}
|
||||
onValueChange={(v) => mutation.mutate({ key: settingKey, value: v })}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
<SelectTrigger className="max-w-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DigestSettingsSection({ settings }: { settings: Record<string, string> }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SettingToggle
|
||||
label="Enable Digest Emails"
|
||||
description="Send periodic digest emails summarizing platform activity"
|
||||
settingKey="digest_enabled"
|
||||
value={settings.digest_enabled || 'false'}
|
||||
/>
|
||||
<SettingSelect
|
||||
label="Default Frequency"
|
||||
description="How often digests are sent to users by default"
|
||||
settingKey="digest_default_frequency"
|
||||
value={settings.digest_default_frequency || 'weekly'}
|
||||
options={[
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
{ value: 'biweekly', label: 'Bi-weekly' },
|
||||
{ value: 'monthly', label: 'Monthly' },
|
||||
]}
|
||||
/>
|
||||
<SettingInput
|
||||
label="Send Hour (UTC)"
|
||||
description="Hour of day when digest emails are sent (0-23)"
|
||||
settingKey="digest_send_hour"
|
||||
value={settings.digest_send_hour || '8'}
|
||||
type="number"
|
||||
/>
|
||||
<div className="border-t pt-4 space-y-3">
|
||||
<Label className="text-sm font-medium">Digest Sections</Label>
|
||||
<SettingToggle
|
||||
label="Include Evaluations"
|
||||
settingKey="digest_include_evaluations"
|
||||
value={settings.digest_include_evaluations || 'true'}
|
||||
/>
|
||||
<SettingToggle
|
||||
label="Include Assignments"
|
||||
settingKey="digest_include_assignments"
|
||||
value={settings.digest_include_assignments || 'true'}
|
||||
/>
|
||||
<SettingToggle
|
||||
label="Include Deadlines"
|
||||
settingKey="digest_include_deadlines"
|
||||
value={settings.digest_include_deadlines || 'true'}
|
||||
/>
|
||||
<SettingToggle
|
||||
label="Include Announcements"
|
||||
settingKey="digest_include_announcements"
|
||||
value={settings.digest_include_announcements || 'true'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AnalyticsSettingsSection({ settings }: { settings: Record<string, string> }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-medium">Observer Tab Visibility</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose which analytics tabs are visible to observers
|
||||
</p>
|
||||
<SettingToggle
|
||||
label="Scores Tab"
|
||||
settingKey="analytics_observer_scores_tab"
|
||||
value={settings.analytics_observer_scores_tab || 'true'}
|
||||
/>
|
||||
<SettingToggle
|
||||
label="Progress Tab"
|
||||
settingKey="analytics_observer_progress_tab"
|
||||
value={settings.analytics_observer_progress_tab || 'true'}
|
||||
/>
|
||||
<SettingToggle
|
||||
label="Juror Stats Tab"
|
||||
settingKey="analytics_observer_juror_tab"
|
||||
value={settings.analytics_observer_juror_tab || 'true'}
|
||||
/>
|
||||
<SettingToggle
|
||||
label="Comparison Tab"
|
||||
settingKey="analytics_observer_comparison_tab"
|
||||
value={settings.analytics_observer_comparison_tab || 'true'}
|
||||
/>
|
||||
<div className="border-t pt-4 space-y-3">
|
||||
<Label className="text-sm font-medium">PDF Reports</Label>
|
||||
<SettingToggle
|
||||
label="Enable PDF Report Generation"
|
||||
description="Allow admins and observers to generate PDF reports"
|
||||
settingKey="analytics_pdf_enabled"
|
||||
value={settings.analytics_pdf_enabled || 'true'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AuditSettingsSection({ settings }: { settings: Record<string, string> }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SettingInput
|
||||
label="Retention Period (days)"
|
||||
description="How long audit log entries are kept before automatic cleanup"
|
||||
settingKey="audit_retention_days"
|
||||
value={settings.audit_retention_days || '365'}
|
||||
type="number"
|
||||
/>
|
||||
<div className="border-t pt-4 space-y-3">
|
||||
<SettingToggle
|
||||
label="Enable Anomaly Detection"
|
||||
description="Detect suspicious patterns like rapid actions or off-hours access"
|
||||
settingKey="anomaly_detection_enabled"
|
||||
value={settings.anomaly_detection_enabled || 'false'}
|
||||
/>
|
||||
<SettingInput
|
||||
label="Rapid Actions Threshold"
|
||||
description="Maximum actions per minute before flagging as anomalous"
|
||||
settingKey="anomaly_rapid_actions_threshold"
|
||||
value={settings.anomaly_rapid_actions_threshold || '30'}
|
||||
type="number"
|
||||
/>
|
||||
<SettingInput
|
||||
label="Off-Hours Start (UTC)"
|
||||
description="Start hour for off-hours monitoring (0-23)"
|
||||
settingKey="anomaly_off_hours_start"
|
||||
value={settings.anomaly_off_hours_start || '22'}
|
||||
type="number"
|
||||
/>
|
||||
<SettingInput
|
||||
label="Off-Hours End (UTC)"
|
||||
description="End hour for off-hours monitoring (0-23)"
|
||||
settingKey="anomaly_off_hours_end"
|
||||
value={settings.anomaly_off_hours_end || '6'}
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LocalizationSettingsSection({ settings }: { settings: Record<string, string> }) {
|
||||
const mutation = useSettingsMutation()
|
||||
const enabledLocales = (settings.localization_enabled_locales || 'en').split(',')
|
||||
|
||||
const toggleLocale = (locale: string) => {
|
||||
const current = new Set(enabledLocales)
|
||||
if (current.has(locale)) {
|
||||
if (current.size <= 1) {
|
||||
toast.error('At least one locale must be enabled')
|
||||
return
|
||||
}
|
||||
current.delete(locale)
|
||||
} else {
|
||||
current.add(locale)
|
||||
}
|
||||
mutation.mutate({
|
||||
key: 'localization_enabled_locales',
|
||||
value: Array.from(current).join(','),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">Enabled Languages</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">EN</span>
|
||||
<span className="text-sm text-muted-foreground">English</span>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={enabledLocales.includes('en')}
|
||||
onCheckedChange={() => toggleLocale('en')}
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">FR</span>
|
||||
<span className="text-sm text-muted-foreground">Français</span>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={enabledLocales.includes('fr')}
|
||||
onCheckedChange={() => toggleLocale('fr')}
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SettingSelect
|
||||
label="Default Locale"
|
||||
description="The default language for new users"
|
||||
settingKey="localization_default_locale"
|
||||
value={settings.localization_default_locale || 'en'}
|
||||
options={[
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'Fran\u00e7ais' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user