From 3bc1cc14c73db3effca9a5c800af6041b268084c Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Apr 2026 18:44:45 +0200 Subject: [PATCH] feat: mentor self-drop with required reason Adds mentor.dropAssignment mutation (mentor-only, ownership-checked, reason min 10 chars). Filters dropped MentorAssignment rows out of getMyProjects, getCandidates mentor count, getMentorPool, and getMenteeActivity so they no longer surface in the mentor or admin UI. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/routers/mentor.ts | 106 ++++++++++++++++- tests/unit/mentor-drop.test.ts | 201 +++++++++++++++++++++++++++++++++ 2 files changed, 302 insertions(+), 5 deletions(-) create mode 100644 tests/unit/mentor-drop.test.ts diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index 22af955..35cf2fe 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -178,7 +178,7 @@ export const mentorRouter = router({ country: true, expertiseTags: true, maxAssignments: true, - mentorAssignments: { select: { id: true } }, + mentorAssignments: { where: { droppedAt: null }, select: { id: true } }, }, }) @@ -1006,7 +1006,10 @@ export const mentorRouter = router({ expertiseTags: true, maxAssignments: true, mentorAssignments: { - where: input.programId ? { project: { programId: input.programId } } : undefined, + where: { + droppedAt: null, + ...(input.programId ? { project: { programId: input.programId } } : {}), + }, select: { completionStatus: true, messages: { @@ -1099,13 +1102,18 @@ export const mentorRouter = router({ method: true, assignedAt: true, completionStatus: true, + droppedAt: true, mentor: { select: { id: true, name: true, email: true, maxAssignments: true, - _count: { select: { mentorAssignments: true } }, + _count: { + select: { + mentorAssignments: { where: { droppedAt: null } }, + }, + }, }, }, messages: { @@ -1137,7 +1145,8 @@ export const mentorRouter = router({ const totals = { unassigned: 0, assigned: 0, active: 0, stalled: 0 } const rows = projects.map((p) => { - const ma = p.mentorAssignment + // Treat a dropped mentor assignment as if no mentor is assigned. + const ma = p.mentorAssignment && !p.mentorAssignment.droppedAt ? p.mentorAssignment : null const lastMessageAt = ma?.messages[0]?.createdAt ?? null const lastFileAt = ma?.files[0]?.createdAt ?? null const lastActivityAt = [lastMessageAt, lastFileAt] @@ -1196,7 +1205,7 @@ export const mentorRouter = router({ */ getMyProjects: mentorProcedure.query(async ({ ctx }) => { const assignments = await ctx.prisma.mentorAssignment.findMany({ - where: { mentorId: ctx.user.id }, + where: { mentorId: ctx.user.id, droppedAt: null }, include: { project: { include: { @@ -2205,4 +2214,91 @@ export const mentorRouter = router({ } return result }), + + /** + * Mentor self-drops an assignment with a required reason. Notifies all + * program admins so they can re-assign. Audit-logged. + */ + dropAssignment: mentorProcedure + .input( + z.object({ + assignmentId: z.string(), + reason: z + .string() + .min(10, 'Reason must be at least 10 characters') + .max(500), + }), + ) + .mutation(async ({ ctx, input }) => { + const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({ + where: { id: input.assignmentId }, + include: { + project: { select: { id: true, title: true } }, + }, + }) + if (assignment.mentorId !== ctx.user.id) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'This is not your assignment', + }) + } + if (assignment.droppedAt) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Assignment is already dropped', + }) + } + if (assignment.completionStatus === 'completed') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Assignment is already completed', + }) + } + + const dropped = await ctx.prisma.mentorAssignment.update({ + where: { id: assignment.id }, + data: { + droppedAt: new Date(), + droppedReason: input.reason, + droppedBy: 'mentor', + }, + }) + + // Notify program admins (best-effort, never block the drop) + try { + const admins = await ctx.prisma.user.findMany({ + where: { + roles: { hasSome: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] }, + status: { not: 'SUSPENDED' }, + }, + select: { id: true }, + }) + const mentorName = ctx.user.name ?? ctx.user.email + for (const admin of admins) { + await createNotification({ + userId: admin.id, + type: NotificationTypes.MENTOR_DROPPED, + title: 'Mentor dropped a team', + message: `${mentorName} dropped their mentee "${assignment.project.title}". Reason: ${input.reason}`, + linkUrl: `/admin/projects/${assignment.project.id}/mentor`, + priority: 'high', + }) + } + } catch (err) { + console.error('[mentor.dropAssignment] notify admins failed:', err) + } + + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'MENTOR_DROP_ASSIGNMENT', + entityType: 'MentorAssignment', + entityId: assignment.id, + detailsJson: { + reason: input.reason, + projectId: assignment.project.id, + }, + }) + return dropped + }), }) diff --git a/tests/unit/mentor-drop.test.ts b/tests/unit/mentor-drop.test.ts new file mode 100644 index 0000000..7929d22 --- /dev/null +++ b/tests/unit/mentor-drop.test.ts @@ -0,0 +1,201 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { prisma, createCaller } from '../setup' +import { + createTestUser, + createTestProgram, + createTestProject, + cleanupTestData, + uid, +} from '../helpers' +import { mentorRouter } from '../../src/server/routers/mentor' + +async function createMentor() { + const id = uid('user') + return prisma.user.create({ + data: { + id, + email: `${id}@test.local`, + name: 'Test Mentor', + role: 'MENTOR', + roles: ['MENTOR'], + status: 'ACTIVE', + }, + }) +} + +describe('mentor.dropAssignment', () => { + const programIds: string[] = [] + const userIds: string[] = [] + + afterAll(async () => { + for (const programId of programIds) { + await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } }) + await cleanupTestData(programId, []) + } + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } + }) + + it('mentor can drop their own assignment with a valid reason', async () => { + const mentor = await createMentor() + userIds.push(mentor.id) + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `drop-ok-${uid()}` }) + programIds.push(program.id) + const project = await createTestProject(program.id, { title: 'P', competitionCategory: 'STARTUP' }) + const ma = await prisma.mentorAssignment.create({ + data: { + projectId: project.id, + mentorId: mentor.id, + method: 'MANUAL', + assignedBy: admin.id, + workspaceEnabled: true, + }, + }) + + const caller = createCaller(mentorRouter, { + id: mentor.id, + email: mentor.email, + role: 'MENTOR', + }) + const result = await caller.dropAssignment({ + assignmentId: ma.id, + reason: 'Schedule conflict — cannot dedicate enough time', + }) + expect(result.droppedAt).not.toBeNull() + expect(result.droppedBy).toBe('mentor') + expect(result.droppedReason).toContain('Schedule conflict') + }) + + it('rejects when reason is too short', async () => { + const mentor = await createMentor() + userIds.push(mentor.id) + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `drop-short-${uid()}` }) + programIds.push(program.id) + const project = await createTestProject(program.id, { + title: 'P', + competitionCategory: 'STARTUP', + }) + const ma = await prisma.mentorAssignment.create({ + data: { + projectId: project.id, + mentorId: mentor.id, + method: 'MANUAL', + assignedBy: admin.id, + }, + }) + const caller = createCaller(mentorRouter, { + id: mentor.id, + email: mentor.email, + role: 'MENTOR', + }) + await expect( + caller.dropAssignment({ assignmentId: ma.id, reason: 'short' }), + ).rejects.toThrow() + }) + + it('rejects when mentor does not own the assignment', async () => { + const mentorA = await createMentor() + const mentorB = await createMentor() + userIds.push(mentorA.id, mentorB.id) + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `drop-foreign-${uid()}` }) + programIds.push(program.id) + const project = await createTestProject(program.id, { + title: 'P', + competitionCategory: 'STARTUP', + }) + const ma = await prisma.mentorAssignment.create({ + data: { + projectId: project.id, + mentorId: mentorA.id, + method: 'MANUAL', + assignedBy: admin.id, + }, + }) + const callerB = createCaller(mentorRouter, { + id: mentorB.id, + email: mentorB.email, + role: 'MENTOR', + }) + await expect( + callerB.dropAssignment({ + assignmentId: ma.id, + reason: 'Trying to steal the drop', + }), + ).rejects.toThrow(/not your assignment/i) + }) + + it('rejects already-dropped assignments', async () => { + const mentor = await createMentor() + userIds.push(mentor.id) + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `drop-twice-${uid()}` }) + programIds.push(program.id) + const project = await createTestProject(program.id, { + title: 'P', + competitionCategory: 'STARTUP', + }) + const ma = await prisma.mentorAssignment.create({ + data: { + projectId: project.id, + mentorId: mentor.id, + method: 'MANUAL', + assignedBy: admin.id, + droppedAt: new Date(), + droppedReason: 'already dropped', + droppedBy: 'mentor', + }, + }) + const caller = createCaller(mentorRouter, { + id: mentor.id, + email: mentor.email, + role: 'MENTOR', + }) + await expect( + caller.dropAssignment({ + assignmentId: ma.id, + reason: 'Trying to drop again', + }), + ).rejects.toThrow(/already dropped/i) + }) + + it('rejects already-completed assignments', async () => { + const mentor = await createMentor() + userIds.push(mentor.id) + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `drop-completed-${uid()}` }) + programIds.push(program.id) + const project = await createTestProject(program.id, { + title: 'P', + competitionCategory: 'STARTUP', + }) + const ma = await prisma.mentorAssignment.create({ + data: { + projectId: project.id, + mentorId: mentor.id, + method: 'MANUAL', + assignedBy: admin.id, + completionStatus: 'completed', + }, + }) + const caller = createCaller(mentorRouter, { + id: mentor.id, + email: mentor.email, + role: 'MENTOR', + }) + await expect( + caller.dropAssignment({ + assignmentId: ma.id, + reason: 'Already wrapped up the project', + }), + ).rejects.toThrow(/already completed/i) + }) +})