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