2026-01-30 13:41:32 +01:00
|
|
|
import { z } from 'zod'
|
|
|
|
|
import { TRPCError } from '@trpc/server'
|
|
|
|
|
import { router, mentorProcedure, adminProcedure } from '../trpc'
|
|
|
|
|
import { MentorAssignmentMethod } from '@prisma/client'
|
|
|
|
|
import {
|
|
|
|
|
getAIMentorSuggestions,
|
|
|
|
|
getRoundRobinMentor,
|
|
|
|
|
} from '../services/mentor-matching'
|
2026-02-04 00:10:51 +01:00
|
|
|
import {
|
|
|
|
|
createNotification,
|
|
|
|
|
notifyProjectTeam,
|
|
|
|
|
NotificationTypes,
|
|
|
|
|
} from '../services/in-app-notification'
|
2026-02-05 21:09:06 +01:00
|
|
|
import { logAudit } from '@/server/utils/audit'
|
2026-01-30 13:41:32 +01:00
|
|
|
|
|
|
|
|
export const mentorRouter = router({
|
|
|
|
|
/**
|
|
|
|
|
* Get AI-suggested mentor matches for a project
|
|
|
|
|
*/
|
|
|
|
|
getSuggestions: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
limit: z.number().min(1).max(10).default(5),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
// Verify project exists
|
|
|
|
|
const project = await ctx.prisma.project.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.projectId },
|
|
|
|
|
include: {
|
|
|
|
|
mentorAssignment: true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (project.mentorAssignment) {
|
|
|
|
|
return {
|
|
|
|
|
currentMentor: project.mentorAssignment,
|
|
|
|
|
suggestions: [],
|
|
|
|
|
message: 'Project already has a mentor assigned',
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const suggestions = await getAIMentorSuggestions(
|
|
|
|
|
ctx.prisma,
|
|
|
|
|
input.projectId,
|
|
|
|
|
input.limit
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-05 20:31:08 +01:00
|
|
|
// Enrich with mentor details (batch query to avoid N+1)
|
|
|
|
|
const mentorIds = suggestions.map((s) => s.mentorId)
|
|
|
|
|
const mentors = await ctx.prisma.user.findMany({
|
|
|
|
|
where: { id: { in: mentorIds } },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
email: true,
|
|
|
|
|
expertiseTags: true,
|
|
|
|
|
mentorAssignments: {
|
|
|
|
|
select: { id: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
const mentorMap = new Map(mentors.map((m) => [m.id, m]))
|
2026-01-30 13:41:32 +01:00
|
|
|
|
2026-02-05 20:31:08 +01:00
|
|
|
const enrichedSuggestions = suggestions.map((suggestion) => {
|
|
|
|
|
const mentor = mentorMap.get(suggestion.mentorId)
|
|
|
|
|
return {
|
|
|
|
|
...suggestion,
|
|
|
|
|
mentor: mentor
|
|
|
|
|
? {
|
|
|
|
|
id: mentor.id,
|
|
|
|
|
name: mentor.name,
|
|
|
|
|
email: mentor.email,
|
|
|
|
|
expertiseTags: mentor.expertiseTags,
|
|
|
|
|
assignmentCount: mentor.mentorAssignments.length,
|
|
|
|
|
}
|
|
|
|
|
: null,
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-01-30 13:41:32 +01:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
currentMentor: null,
|
|
|
|
|
suggestions: enrichedSuggestions.filter((s) => s.mentor !== null),
|
|
|
|
|
message: null,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Manually assign a mentor to a project
|
|
|
|
|
*/
|
|
|
|
|
assign: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
mentorId: z.string(),
|
|
|
|
|
method: z.nativeEnum(MentorAssignmentMethod).default('MANUAL'),
|
|
|
|
|
aiConfidenceScore: z.number().optional(),
|
|
|
|
|
expertiseMatchScore: z.number().optional(),
|
|
|
|
|
aiReasoning: z.string().optional(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Verify project exists and doesn't have a mentor
|
|
|
|
|
const project = await ctx.prisma.project.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.projectId },
|
|
|
|
|
include: { mentorAssignment: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (project.mentorAssignment) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'CONFLICT',
|
|
|
|
|
message: 'Project already has a mentor assigned',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify mentor exists
|
|
|
|
|
const mentor = await ctx.prisma.user.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.mentorId },
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-05 21:09:06 +01:00
|
|
|
// Create assignment + audit log in transaction
|
|
|
|
|
const assignment = await ctx.prisma.$transaction(async (tx) => {
|
|
|
|
|
const created = await tx.mentorAssignment.create({
|
|
|
|
|
data: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
mentorId: input.mentorId,
|
|
|
|
|
method: input.method,
|
|
|
|
|
assignedBy: ctx.user.id,
|
|
|
|
|
aiConfidenceScore: input.aiConfidenceScore,
|
|
|
|
|
expertiseMatchScore: input.expertiseMatchScore,
|
|
|
|
|
aiReasoning: input.aiReasoning,
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
2026-02-05 21:09:06 +01:00
|
|
|
include: {
|
|
|
|
|
mentor: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
email: true,
|
|
|
|
|
expertiseTags: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
project: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
title: true,
|
|
|
|
|
},
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
},
|
2026-02-05 21:09:06 +01:00
|
|
|
})
|
2026-01-30 13:41:32 +01:00
|
|
|
|
2026-02-05 21:09:06 +01:00
|
|
|
await logAudit({
|
|
|
|
|
prisma: tx,
|
2026-01-30 13:41:32 +01:00
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'MENTOR_ASSIGN',
|
|
|
|
|
entityType: 'MentorAssignment',
|
2026-02-05 21:09:06 +01:00
|
|
|
entityId: created.id,
|
2026-01-30 13:41:32 +01:00
|
|
|
detailsJson: {
|
|
|
|
|
projectId: input.projectId,
|
2026-02-05 21:09:06 +01:00
|
|
|
projectTitle: created.project.title,
|
2026-01-30 13:41:32 +01:00
|
|
|
mentorId: input.mentorId,
|
2026-02-05 21:09:06 +01:00
|
|
|
mentorName: created.mentor.name,
|
2026-01-30 13:41:32 +01:00
|
|
|
method: input.method,
|
|
|
|
|
},
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
2026-02-05 21:09:06 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return created
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
|
2026-02-04 00:10:51 +01:00
|
|
|
// Get team lead info for mentor notification
|
|
|
|
|
const teamLead = await ctx.prisma.teamMember.findFirst({
|
|
|
|
|
where: { projectId: input.projectId, role: 'LEAD' },
|
|
|
|
|
include: { user: { select: { name: true, email: true } } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Notify mentor of new mentee
|
|
|
|
|
await createNotification({
|
|
|
|
|
userId: input.mentorId,
|
|
|
|
|
type: NotificationTypes.MENTEE_ASSIGNED,
|
|
|
|
|
title: 'New Mentee Assigned',
|
|
|
|
|
message: `You have been assigned to mentor "${assignment.project.title}".`,
|
|
|
|
|
linkUrl: `/mentor/projects/${input.projectId}`,
|
|
|
|
|
linkLabel: 'View Project',
|
|
|
|
|
priority: 'high',
|
|
|
|
|
metadata: {
|
|
|
|
|
projectName: assignment.project.title,
|
|
|
|
|
teamLeadName: teamLead?.user?.name || 'Team Lead',
|
|
|
|
|
teamLeadEmail: teamLead?.user?.email,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Notify project team of mentor assignment
|
|
|
|
|
await notifyProjectTeam(input.projectId, {
|
|
|
|
|
type: NotificationTypes.MENTOR_ASSIGNED,
|
|
|
|
|
title: 'Mentor Assigned',
|
|
|
|
|
message: `${assignment.mentor.name || 'A mentor'} has been assigned to support your project.`,
|
|
|
|
|
linkUrl: `/team/projects/${input.projectId}`,
|
|
|
|
|
linkLabel: 'View Project',
|
|
|
|
|
priority: 'high',
|
|
|
|
|
metadata: {
|
|
|
|
|
projectName: assignment.project.title,
|
|
|
|
|
mentorName: assignment.mentor.name,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
return assignment
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Auto-assign a mentor using AI or round-robin
|
|
|
|
|
*/
|
|
|
|
|
autoAssign: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
useAI: z.boolean().default(true),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Verify project exists and doesn't have a mentor
|
|
|
|
|
const project = await ctx.prisma.project.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.projectId },
|
|
|
|
|
include: { mentorAssignment: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (project.mentorAssignment) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'CONFLICT',
|
|
|
|
|
message: 'Project already has a mentor assigned',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mentorId: string | null = null
|
|
|
|
|
let method: MentorAssignmentMethod = 'ALGORITHM'
|
|
|
|
|
let aiConfidenceScore: number | undefined
|
|
|
|
|
let expertiseMatchScore: number | undefined
|
|
|
|
|
let aiReasoning: string | undefined
|
|
|
|
|
|
|
|
|
|
if (input.useAI) {
|
|
|
|
|
// Try AI matching first
|
|
|
|
|
const suggestions = await getAIMentorSuggestions(ctx.prisma, input.projectId, 1)
|
|
|
|
|
|
|
|
|
|
if (suggestions.length > 0) {
|
|
|
|
|
const best = suggestions[0]
|
|
|
|
|
mentorId = best.mentorId
|
|
|
|
|
method = 'AI_AUTO'
|
|
|
|
|
aiConfidenceScore = best.confidenceScore
|
|
|
|
|
expertiseMatchScore = best.expertiseMatchScore
|
|
|
|
|
aiReasoning = best.reasoning
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback to round-robin
|
|
|
|
|
if (!mentorId) {
|
|
|
|
|
mentorId = await getRoundRobinMentor(ctx.prisma)
|
|
|
|
|
method = 'ALGORITHM'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!mentorId) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'No available mentors found',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create assignment
|
|
|
|
|
const assignment = await ctx.prisma.mentorAssignment.create({
|
|
|
|
|
data: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
mentorId,
|
|
|
|
|
method,
|
|
|
|
|
assignedBy: ctx.user.id,
|
|
|
|
|
aiConfidenceScore,
|
|
|
|
|
expertiseMatchScore,
|
|
|
|
|
aiReasoning,
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
mentor: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
email: true,
|
|
|
|
|
expertiseTags: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
project: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
title: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Create audit log
|
2026-02-05 21:09:06 +01:00
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'MENTOR_AUTO_ASSIGN',
|
|
|
|
|
entityType: 'MentorAssignment',
|
|
|
|
|
entityId: assignment.id,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
projectTitle: assignment.project.title,
|
|
|
|
|
mentorId,
|
|
|
|
|
mentorName: assignment.mentor.name,
|
|
|
|
|
method,
|
|
|
|
|
aiConfidenceScore,
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
2026-02-05 21:09:06 +01:00
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
|
2026-02-04 00:10:51 +01:00
|
|
|
// Get team lead info for mentor notification
|
|
|
|
|
const teamLead = await ctx.prisma.teamMember.findFirst({
|
|
|
|
|
where: { projectId: input.projectId, role: 'LEAD' },
|
|
|
|
|
include: { user: { select: { name: true, email: true } } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Notify mentor of new mentee
|
|
|
|
|
await createNotification({
|
|
|
|
|
userId: mentorId,
|
|
|
|
|
type: NotificationTypes.MENTEE_ASSIGNED,
|
|
|
|
|
title: 'New Mentee Assigned',
|
|
|
|
|
message: `You have been assigned to mentor "${assignment.project.title}".`,
|
|
|
|
|
linkUrl: `/mentor/projects/${input.projectId}`,
|
|
|
|
|
linkLabel: 'View Project',
|
|
|
|
|
priority: 'high',
|
|
|
|
|
metadata: {
|
|
|
|
|
projectName: assignment.project.title,
|
|
|
|
|
teamLeadName: teamLead?.user?.name || 'Team Lead',
|
|
|
|
|
teamLeadEmail: teamLead?.user?.email,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Notify project team of mentor assignment
|
|
|
|
|
await notifyProjectTeam(input.projectId, {
|
|
|
|
|
type: NotificationTypes.MENTOR_ASSIGNED,
|
|
|
|
|
title: 'Mentor Assigned',
|
|
|
|
|
message: `${assignment.mentor.name || 'A mentor'} has been assigned to support your project.`,
|
|
|
|
|
linkUrl: `/team/projects/${input.projectId}`,
|
|
|
|
|
linkLabel: 'View Project',
|
|
|
|
|
priority: 'high',
|
|
|
|
|
metadata: {
|
|
|
|
|
projectName: assignment.project.title,
|
|
|
|
|
mentorName: assignment.mentor.name,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
return assignment
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Remove mentor assignment
|
|
|
|
|
*/
|
|
|
|
|
unassign: adminProcedure
|
|
|
|
|
.input(z.object({ projectId: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const assignment = await ctx.prisma.mentorAssignment.findUnique({
|
|
|
|
|
where: { projectId: input.projectId },
|
|
|
|
|
include: {
|
|
|
|
|
mentor: { select: { id: true, name: true } },
|
|
|
|
|
project: { select: { id: true, title: true } },
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!assignment) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'No mentor assignment found for this project',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 21:09:06 +01:00
|
|
|
// Delete assignment + audit log in transaction
|
|
|
|
|
await ctx.prisma.$transaction(async (tx) => {
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: tx,
|
2026-01-30 13:41:32 +01:00
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'MENTOR_UNASSIGN',
|
|
|
|
|
entityType: 'MentorAssignment',
|
|
|
|
|
entityId: assignment.id,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
projectTitle: assignment.project.title,
|
|
|
|
|
mentorId: assignment.mentor.id,
|
|
|
|
|
mentorName: assignment.mentor.name,
|
|
|
|
|
},
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
2026-02-05 21:09:06 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await tx.mentorAssignment.delete({
|
|
|
|
|
where: { projectId: input.projectId },
|
|
|
|
|
})
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { success: true }
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Bulk auto-assign mentors to projects without one
|
|
|
|
|
*/
|
|
|
|
|
bulkAutoAssign: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
roundId: z.string(),
|
|
|
|
|
useAI: z.boolean().default(true),
|
|
|
|
|
maxAssignments: z.number().min(1).max(100).default(50),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Get projects without mentors
|
|
|
|
|
const projects = await ctx.prisma.project.findMany({
|
|
|
|
|
where: {
|
2026-02-04 14:15:06 +01:00
|
|
|
roundId: input.roundId,
|
2026-01-30 13:41:32 +01:00
|
|
|
mentorAssignment: null,
|
|
|
|
|
wantsMentorship: true,
|
|
|
|
|
},
|
|
|
|
|
select: { id: true },
|
|
|
|
|
take: input.maxAssignments,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (projects.length === 0) {
|
|
|
|
|
return {
|
|
|
|
|
assigned: 0,
|
|
|
|
|
failed: 0,
|
|
|
|
|
message: 'No projects need mentor assignment',
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let assigned = 0
|
|
|
|
|
let failed = 0
|
|
|
|
|
|
|
|
|
|
for (const project of projects) {
|
|
|
|
|
try {
|
|
|
|
|
let mentorId: string | null = null
|
|
|
|
|
let method: MentorAssignmentMethod = 'ALGORITHM'
|
|
|
|
|
let aiConfidenceScore: number | undefined
|
|
|
|
|
let expertiseMatchScore: number | undefined
|
|
|
|
|
let aiReasoning: string | undefined
|
|
|
|
|
|
|
|
|
|
if (input.useAI) {
|
|
|
|
|
const suggestions = await getAIMentorSuggestions(ctx.prisma, project.id, 1)
|
|
|
|
|
if (suggestions.length > 0) {
|
|
|
|
|
const best = suggestions[0]
|
|
|
|
|
mentorId = best.mentorId
|
|
|
|
|
method = 'AI_AUTO'
|
|
|
|
|
aiConfidenceScore = best.confidenceScore
|
|
|
|
|
expertiseMatchScore = best.expertiseMatchScore
|
|
|
|
|
aiReasoning = best.reasoning
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!mentorId) {
|
|
|
|
|
mentorId = await getRoundRobinMentor(ctx.prisma)
|
|
|
|
|
method = 'ALGORITHM'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (mentorId) {
|
2026-02-04 00:10:51 +01:00
|
|
|
const assignment = await ctx.prisma.mentorAssignment.create({
|
2026-01-30 13:41:32 +01:00
|
|
|
data: {
|
|
|
|
|
projectId: project.id,
|
|
|
|
|
mentorId,
|
|
|
|
|
method,
|
|
|
|
|
assignedBy: ctx.user.id,
|
|
|
|
|
aiConfidenceScore,
|
|
|
|
|
expertiseMatchScore,
|
|
|
|
|
aiReasoning,
|
|
|
|
|
},
|
2026-02-04 00:10:51 +01:00
|
|
|
include: {
|
|
|
|
|
mentor: { select: { name: true } },
|
|
|
|
|
project: { select: { title: true } },
|
|
|
|
|
},
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
2026-02-04 00:10:51 +01:00
|
|
|
|
|
|
|
|
// Get team lead info
|
|
|
|
|
const teamLead = await ctx.prisma.teamMember.findFirst({
|
|
|
|
|
where: { projectId: project.id, role: 'LEAD' },
|
|
|
|
|
include: { user: { select: { name: true, email: true } } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Notify mentor
|
|
|
|
|
await createNotification({
|
|
|
|
|
userId: mentorId,
|
|
|
|
|
type: NotificationTypes.MENTEE_ASSIGNED,
|
|
|
|
|
title: 'New Mentee Assigned',
|
|
|
|
|
message: `You have been assigned to mentor "${assignment.project.title}".`,
|
|
|
|
|
linkUrl: `/mentor/projects/${project.id}`,
|
|
|
|
|
linkLabel: 'View Project',
|
|
|
|
|
priority: 'high',
|
|
|
|
|
metadata: {
|
|
|
|
|
projectName: assignment.project.title,
|
|
|
|
|
teamLeadName: teamLead?.user?.name || 'Team Lead',
|
|
|
|
|
teamLeadEmail: teamLead?.user?.email,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Notify project team
|
|
|
|
|
await notifyProjectTeam(project.id, {
|
|
|
|
|
type: NotificationTypes.MENTOR_ASSIGNED,
|
|
|
|
|
title: 'Mentor Assigned',
|
|
|
|
|
message: `${assignment.mentor.name || 'A mentor'} has been assigned to support your project.`,
|
|
|
|
|
linkUrl: `/team/projects/${project.id}`,
|
|
|
|
|
linkLabel: 'View Project',
|
|
|
|
|
priority: 'high',
|
|
|
|
|
metadata: {
|
|
|
|
|
projectName: assignment.project.title,
|
|
|
|
|
mentorName: assignment.mentor.name,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
assigned++
|
|
|
|
|
} else {
|
|
|
|
|
failed++
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
failed++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create audit log
|
2026-02-05 21:09:06 +01:00
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'MENTOR_BULK_ASSIGN',
|
|
|
|
|
entityType: 'Round',
|
|
|
|
|
entityId: input.roundId,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
assigned,
|
|
|
|
|
failed,
|
|
|
|
|
useAI: input.useAI,
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
2026-02-05 21:09:06 +01:00
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
assigned,
|
|
|
|
|
failed,
|
|
|
|
|
message: `Assigned ${assigned} mentor(s), ${failed} failed`,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get mentor's assigned projects
|
|
|
|
|
*/
|
|
|
|
|
getMyProjects: mentorProcedure.query(async ({ ctx }) => {
|
|
|
|
|
const assignments = await ctx.prisma.mentorAssignment.findMany({
|
|
|
|
|
where: { mentorId: ctx.user.id },
|
|
|
|
|
include: {
|
|
|
|
|
project: {
|
|
|
|
|
include: {
|
2026-02-04 14:15:06 +01:00
|
|
|
round: {
|
2026-01-30 13:41:32 +01:00
|
|
|
include: {
|
2026-02-04 14:15:06 +01:00
|
|
|
program: { select: { name: true, year: true } },
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
teamMembers: {
|
|
|
|
|
include: {
|
|
|
|
|
user: { select: { id: true, name: true, email: true } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: { assignedAt: 'desc' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return assignments
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get detailed project info for a mentor's assigned project
|
|
|
|
|
*/
|
|
|
|
|
getProjectDetail: mentorProcedure
|
|
|
|
|
.input(z.object({ projectId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
// Verify the mentor is assigned to this project
|
|
|
|
|
const assignment = await ctx.prisma.mentorAssignment.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
mentorId: ctx.user.id,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Allow admins to access any project
|
|
|
|
|
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
|
|
|
|
|
|
|
|
|
if (!assignment && !isAdmin) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'You are not assigned to mentor this project',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const project = await ctx.prisma.project.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.projectId },
|
|
|
|
|
include: {
|
2026-02-04 14:15:06 +01:00
|
|
|
round: {
|
2026-01-30 13:41:32 +01:00
|
|
|
include: {
|
2026-02-04 14:15:06 +01:00
|
|
|
program: { select: { id: true, name: true, year: true } },
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
teamMembers: {
|
|
|
|
|
include: {
|
|
|
|
|
user: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
email: true,
|
|
|
|
|
phoneNumber: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: { role: 'asc' },
|
|
|
|
|
},
|
|
|
|
|
files: {
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
},
|
|
|
|
|
mentorAssignment: {
|
|
|
|
|
include: {
|
|
|
|
|
mentor: {
|
|
|
|
|
select: { id: true, name: true, email: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...project,
|
|
|
|
|
assignedAt: assignment?.assignedAt,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* Send a message to the project team (mentor side)
|
|
|
|
|
*/
|
|
|
|
|
sendMessage: mentorProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
message: z.string().min(1).max(5000),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Verify the mentor is assigned to this project
|
|
|
|
|
const assignment = await ctx.prisma.mentorAssignment.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
mentorId: ctx.user.id,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
|
|
|
|
|
|
|
|
|
if (!assignment && !isAdmin) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'You are not assigned to mentor this project',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const mentorMessage = await ctx.prisma.mentorMessage.create({
|
|
|
|
|
data: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
senderId: ctx.user.id,
|
|
|
|
|
message: input.message,
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
sender: {
|
|
|
|
|
select: { id: true, name: true, email: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Notify project team members
|
|
|
|
|
await notifyProjectTeam(input.projectId, {
|
|
|
|
|
type: 'MENTOR_MESSAGE',
|
|
|
|
|
title: 'New Message from Mentor',
|
|
|
|
|
message: `${ctx.user.name || 'Your mentor'} sent you a message`,
|
|
|
|
|
linkUrl: `/my-submission/${input.projectId}`,
|
|
|
|
|
linkLabel: 'View Message',
|
|
|
|
|
priority: 'normal',
|
|
|
|
|
metadata: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return mentorMessage
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get messages for a project (mentor side)
|
|
|
|
|
*/
|
|
|
|
|
getMessages: mentorProcedure
|
|
|
|
|
.input(z.object({ projectId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
// Verify the mentor is assigned to this project
|
|
|
|
|
const assignment = await ctx.prisma.mentorAssignment.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
mentorId: ctx.user.id,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
|
|
|
|
|
|
|
|
|
if (!assignment && !isAdmin) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'You are not assigned to mentor this project',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const messages = await ctx.prisma.mentorMessage.findMany({
|
|
|
|
|
where: { projectId: input.projectId },
|
|
|
|
|
include: {
|
|
|
|
|
sender: {
|
|
|
|
|
select: { id: true, name: true, email: true, role: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: { createdAt: 'asc' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Mark unread messages from the team as read
|
|
|
|
|
await ctx.prisma.mentorMessage.updateMany({
|
|
|
|
|
where: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
senderId: { not: ctx.user.id },
|
|
|
|
|
isRead: false,
|
|
|
|
|
},
|
|
|
|
|
data: { isRead: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return messages
|
|
|
|
|
}),
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
/**
|
|
|
|
|
* List all mentor assignments (admin)
|
|
|
|
|
*/
|
|
|
|
|
listAssignments: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
roundId: z.string().optional(),
|
|
|
|
|
mentorId: z.string().optional(),
|
|
|
|
|
page: z.number().min(1).default(1),
|
|
|
|
|
perPage: z.number().min(1).max(100).default(20),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const where = {
|
2026-02-04 14:15:06 +01:00
|
|
|
...(input.roundId && { project: { roundId: input.roundId } }),
|
2026-01-30 13:41:32 +01:00
|
|
|
...(input.mentorId && { mentorId: input.mentorId }),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [assignments, total] = await Promise.all([
|
|
|
|
|
ctx.prisma.mentorAssignment.findMany({
|
|
|
|
|
where,
|
|
|
|
|
include: {
|
|
|
|
|
project: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
title: true,
|
|
|
|
|
teamName: true,
|
|
|
|
|
oceanIssue: true,
|
|
|
|
|
competitionCategory: true,
|
2026-02-04 14:15:06 +01:00
|
|
|
status: true,
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
mentor: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
email: true,
|
|
|
|
|
expertiseTags: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: { assignedAt: 'desc' },
|
|
|
|
|
skip: (input.page - 1) * input.perPage,
|
|
|
|
|
take: input.perPage,
|
|
|
|
|
}),
|
|
|
|
|
ctx.prisma.mentorAssignment.count({ where }),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
assignments,
|
|
|
|
|
total,
|
|
|
|
|
page: input.page,
|
|
|
|
|
perPage: input.perPage,
|
|
|
|
|
totalPages: Math.ceil(total / input.perPage),
|
|
|
|
|
}
|
|
|
|
|
}),
|
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>
2026-02-05 23:31:41 +01:00
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// 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())
|
|
|
|
|
}),
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|