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

@@ -794,4 +794,502 @@ export const mentorRouter = router({
totalPages: Math.ceil(total / input.perPage),
}
}),
// =========================================================================
// Mentor Notes CRUD (F8)
// =========================================================================
/**
* Create a mentor note for an assignment
*/
createNote: mentorProcedure
.input(
z.object({
mentorAssignmentId: z.string(),
content: z.string().min(1).max(10000),
isVisibleToAdmin: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
// Verify the user owns this assignment or is admin
const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
where: { id: input.mentorAssignmentId },
select: { mentorId: true, projectId: true },
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (assignment.mentorId !== ctx.user.id && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to this mentorship',
})
}
const note = await ctx.prisma.mentorNote.create({
data: {
mentorAssignmentId: input.mentorAssignmentId,
authorId: ctx.user.id,
content: input.content,
isVisibleToAdmin: input.isVisibleToAdmin,
},
include: {
author: { select: { id: true, name: true, email: true } },
},
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE_MENTOR_NOTE',
entityType: 'MentorNote',
entityId: note.id,
detailsJson: {
mentorAssignmentId: input.mentorAssignmentId,
projectId: assignment.projectId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Audit log errors should never break the operation
}
return note
}),
/**
* Update a mentor note
*/
updateNote: mentorProcedure
.input(
z.object({
noteId: z.string(),
content: z.string().min(1).max(10000),
isVisibleToAdmin: z.boolean().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const note = await ctx.prisma.mentorNote.findUniqueOrThrow({
where: { id: input.noteId },
select: { authorId: true },
})
if (note.authorId !== ctx.user.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You can only edit your own notes',
})
}
return ctx.prisma.mentorNote.update({
where: { id: input.noteId },
data: {
content: input.content,
...(input.isVisibleToAdmin !== undefined && { isVisibleToAdmin: input.isVisibleToAdmin }),
},
include: {
author: { select: { id: true, name: true, email: true } },
},
})
}),
/**
* Delete a mentor note
*/
deleteNote: mentorProcedure
.input(z.object({ noteId: z.string() }))
.mutation(async ({ ctx, input }) => {
const note = await ctx.prisma.mentorNote.findUniqueOrThrow({
where: { id: input.noteId },
select: { authorId: true },
})
if (note.authorId !== ctx.user.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You can only delete your own notes',
})
}
return ctx.prisma.mentorNote.delete({
where: { id: input.noteId },
})
}),
/**
* Get notes for a mentor assignment
*/
getNotes: mentorProcedure
.input(z.object({ mentorAssignmentId: z.string() }))
.query(async ({ ctx, input }) => {
const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
where: { id: input.mentorAssignmentId },
select: { mentorId: true },
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (assignment.mentorId !== ctx.user.id && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to this mentorship',
})
}
// Admins see all notes; mentors see only their own
const where: Record<string, unknown> = { mentorAssignmentId: input.mentorAssignmentId }
if (!isAdmin) {
where.authorId = ctx.user.id
}
return ctx.prisma.mentorNote.findMany({
where,
include: {
author: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: 'desc' },
})
}),
// =========================================================================
// Milestone Operations (F8)
// =========================================================================
/**
* Get milestones for a program with completion status
*/
getMilestones: mentorProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const milestones = await ctx.prisma.mentorMilestone.findMany({
where: { programId: input.programId },
include: {
completions: {
include: {
mentorAssignment: { select: { id: true, projectId: true } },
},
},
},
orderBy: { sortOrder: 'asc' },
})
// Get current user's assignments for completion status context
const myAssignments = await ctx.prisma.mentorAssignment.findMany({
where: { mentorId: ctx.user.id },
select: { id: true, projectId: true },
})
const myAssignmentIds = new Set(myAssignments.map((a) => a.id))
return milestones.map((milestone) => ({
...milestone,
myCompletions: milestone.completions.filter((c) =>
myAssignmentIds.has(c.mentorAssignmentId)
),
}))
}),
/**
* Mark a milestone as completed for an assignment
*/
completeMilestone: mentorProcedure
.input(
z.object({
milestoneId: z.string(),
mentorAssignmentId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
// Verify the user owns this assignment
const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
where: { id: input.mentorAssignmentId },
select: { mentorId: true, projectId: true },
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (assignment.mentorId !== ctx.user.id && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to this mentorship',
})
}
const completion = await ctx.prisma.mentorMilestoneCompletion.create({
data: {
milestoneId: input.milestoneId,
mentorAssignmentId: input.mentorAssignmentId,
completedById: ctx.user.id,
},
})
// Check if all required milestones are now completed
const milestone = await ctx.prisma.mentorMilestone.findUniqueOrThrow({
where: { id: input.milestoneId },
select: { programId: true },
})
const requiredMilestones = await ctx.prisma.mentorMilestone.findMany({
where: { programId: milestone.programId, isRequired: true },
select: { id: true },
})
const completedMilestones = await ctx.prisma.mentorMilestoneCompletion.findMany({
where: {
mentorAssignmentId: input.mentorAssignmentId,
milestoneId: { in: requiredMilestones.map((m) => m.id) },
},
select: { milestoneId: true },
})
const allRequiredDone = requiredMilestones.length > 0 &&
completedMilestones.length >= requiredMilestones.length
if (allRequiredDone) {
await ctx.prisma.mentorAssignment.update({
where: { id: input.mentorAssignmentId },
data: { completionStatus: 'completed' },
})
}
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'COMPLETE_MILESTONE',
entityType: 'MentorMilestoneCompletion',
entityId: completion.id,
detailsJson: {
milestoneId: input.milestoneId,
mentorAssignmentId: input.mentorAssignmentId,
allRequiredDone,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Audit log errors should never break the operation
}
return { completion, allRequiredDone }
}),
/**
* Uncomplete a milestone for an assignment
*/
uncompleteMilestone: mentorProcedure
.input(
z.object({
milestoneId: z.string(),
mentorAssignmentId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
where: { id: input.mentorAssignmentId },
select: { mentorId: true },
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (assignment.mentorId !== ctx.user.id && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to this mentorship',
})
}
await ctx.prisma.mentorMilestoneCompletion.delete({
where: {
milestoneId_mentorAssignmentId: {
milestoneId: input.milestoneId,
mentorAssignmentId: input.mentorAssignmentId,
},
},
})
// Revert completion status if it was completed
await ctx.prisma.mentorAssignment.update({
where: { id: input.mentorAssignmentId },
data: { completionStatus: 'in_progress' },
})
return { success: true }
}),
// =========================================================================
// Admin Milestone Management (F8)
// =========================================================================
/**
* Create a milestone for a program
*/
createMilestone: adminProcedure
.input(
z.object({
programId: z.string(),
name: z.string().min(1).max(255),
description: z.string().max(2000).optional(),
isRequired: z.boolean().default(false),
deadlineOffsetDays: z.number().int().optional().nullable(),
sortOrder: z.number().int().default(0),
})
)
.mutation(async ({ ctx, input }) => {
return ctx.prisma.mentorMilestone.create({
data: input,
})
}),
/**
* Update a milestone
*/
updateMilestone: adminProcedure
.input(
z.object({
milestoneId: z.string(),
name: z.string().min(1).max(255).optional(),
description: z.string().max(2000).optional().nullable(),
isRequired: z.boolean().optional(),
deadlineOffsetDays: z.number().int().optional().nullable(),
sortOrder: z.number().int().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { milestoneId, ...data } = input
return ctx.prisma.mentorMilestone.update({
where: { id: milestoneId },
data,
})
}),
/**
* Delete a milestone (cascades completions)
*/
deleteMilestone: adminProcedure
.input(z.object({ milestoneId: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.mentorMilestone.delete({
where: { id: input.milestoneId },
})
}),
/**
* Reorder milestones
*/
reorderMilestones: adminProcedure
.input(
z.object({
milestoneIds: z.array(z.string()),
})
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.$transaction(
input.milestoneIds.map((id, index) =>
ctx.prisma.mentorMilestone.update({
where: { id },
data: { sortOrder: index },
})
)
)
return { success: true }
}),
// =========================================================================
// Activity Tracking (F8)
// =========================================================================
/**
* Track a mentor's view of an assignment
*/
trackView: mentorProcedure
.input(z.object({ mentorAssignmentId: z.string() }))
.mutation(async ({ ctx, input }) => {
const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
where: { id: input.mentorAssignmentId },
select: { mentorId: true },
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (assignment.mentorId !== ctx.user.id && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to this mentorship',
})
}
return ctx.prisma.mentorAssignment.update({
where: { id: input.mentorAssignmentId },
data: { lastViewedAt: new Date() },
})
}),
/**
* Get activity stats for all mentors (admin)
*/
getActivityStats: adminProcedure
.input(
z.object({
roundId: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const where = input.roundId
? { project: { roundId: input.roundId } }
: {}
const assignments = await ctx.prisma.mentorAssignment.findMany({
where,
include: {
mentor: { select: { id: true, name: true, email: true } },
project: { select: { id: true, title: true } },
notes: { select: { id: true } },
milestoneCompletions: { select: { id: true } },
},
})
// Get message counts per mentor
const mentorIds = [...new Set(assignments.map((a) => a.mentorId))]
const messageCounts = await ctx.prisma.mentorMessage.groupBy({
by: ['senderId'],
where: { senderId: { in: mentorIds } },
_count: true,
})
const messageCountMap = new Map(messageCounts.map((m) => [m.senderId, m._count]))
// Build per-mentor stats
const mentorStats = new Map<string, {
mentor: { id: string; name: string | null; email: string }
assignments: number
lastViewedAt: Date | null
notesCount: number
milestonesCompleted: number
messagesSent: number
completionStatuses: string[]
}>()
for (const assignment of assignments) {
const existing = mentorStats.get(assignment.mentorId)
if (existing) {
existing.assignments++
existing.notesCount += assignment.notes.length
existing.milestonesCompleted += assignment.milestoneCompletions.length
existing.completionStatuses.push(assignment.completionStatus)
if (assignment.lastViewedAt && (!existing.lastViewedAt || assignment.lastViewedAt > existing.lastViewedAt)) {
existing.lastViewedAt = assignment.lastViewedAt
}
} else {
mentorStats.set(assignment.mentorId, {
mentor: assignment.mentor,
assignments: 1,
lastViewedAt: assignment.lastViewedAt,
notesCount: assignment.notes.length,
milestonesCompleted: assignment.milestoneCompletions.length,
messagesSent: messageCountMap.get(assignment.mentorId) || 0,
completionStatuses: [assignment.completionStatus],
})
}
}
return Array.from(mentorStats.values())
}),
})