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