From 66110598a02340ac9d8c036291c0c0d86e972810 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 22 May 2026 16:37:37 +0200 Subject: [PATCH] =?UTF-8?q?refactor(schema-cascade):=20rename=20Project.me?= =?UTF-8?q?ntorAssignment=20=E2=86=92=20mentorAssignments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema dropped @unique on MentorAssignment.projectId in PR8 Task 1 → back-relation becomes a list. Mechanical rename of Prisma queries and consumer accessors. Legacy single-mentor callers use [0] with a TODO for PR8 Task 8 to surface the full list. mentor-workspace.ts is left as Task 5. - routers (mentor, project, applicant, finalist, round) and smart-assignment service: include/where/select keys renamed; `mentorAssignment: null` → `mentorAssignments: { none: {} }`; `{ isNot: null }` → `{ some: {} }`. - UI consumers (mentor + applicant pages): `project.mentorAssignment` → `project.mentorAssignments[0]` with TODO markers. - Tests: `findUnique({ projectId })` → `findFirst({ projectId })` since the composite key now requires both projectId+mentorId. MentorFile.create gains the new required projectId. - Workspace endpoints in mentor.ts now guard null mentorAssignmentId until Task 5 re-scopes them to project. - finalist.unconfirm now cascades to ALL active mentor assignments. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(applicant)/applicant/mentor/page.tsx | 9 ++- src/app/(applicant)/applicant/team/page.tsx | 6 +- .../(mentor)/mentor/projects/[id]/page.tsx | 14 ++-- src/server/routers/applicant.ts | 25 ++++--- src/server/routers/finalist.ts | 12 ++-- src/server/routers/mentor.ts | 66 ++++++++++++++----- src/server/routers/project.ts | 47 +++++++------ src/server/routers/round.ts | 2 +- src/server/services/smart-assignment.ts | 4 +- tests/unit/mentor-assignment-ux.test.ts | 12 ++-- tests/unit/mentor-round-stats.test.ts | 1 + 11 files changed, 127 insertions(+), 71 deletions(-) diff --git a/src/app/(applicant)/applicant/mentor/page.tsx b/src/app/(applicant)/applicant/mentor/page.tsx index 289bf71..4f35cf2 100644 --- a/src/app/(applicant)/applicant/mentor/page.tsx +++ b/src/app/(applicant)/applicant/mentor/page.tsx @@ -72,7 +72,10 @@ export default function ApplicantMentorPage() { ) } - const mentor = dashboardData?.project?.mentorAssignment?.mentor + // TODO(PR8 Task 7): show ALL assigned mentors. For now we display only the + // first one until the multi-mentor applicant UI ships. + const primaryAssignment = dashboardData?.project?.mentorAssignments?.[0] ?? null + const mentor = primaryAssignment?.mentor return (
@@ -136,9 +139,9 @@ export default function ApplicantMentorPage() { )} {/* Files */} - {dashboardData?.project?.mentorAssignment?.id && ( + {primaryAssignment?.id && ( )} diff --git a/src/app/(applicant)/applicant/team/page.tsx b/src/app/(applicant)/applicant/team/page.tsx index 0a6528c..c0ad72e 100644 --- a/src/app/(applicant)/applicant/team/page.tsx +++ b/src/app/(applicant)/applicant/team/page.tsx @@ -357,12 +357,12 @@ export default function ApplicantProjectPage() { )}
- {/* Mentor info */} - {project.mentorAssignment?.mentor && ( + {/* Mentor info — TODO(PR8 Task 7): list ALL assigned mentors */} + {project.mentorAssignments?.[0]?.mentor && (

Assigned Mentor

- {project.mentorAssignment.mentor.name} ({project.mentorAssignment.mentor.email}) + {project.mentorAssignments[0].mentor.name} ({project.mentorAssignments[0].mentor.email})

)} diff --git a/src/app/(mentor)/mentor/projects/[id]/page.tsx b/src/app/(mentor)/mentor/projects/[id]/page.tsx index c347c5a..15966a6 100644 --- a/src/app/(mentor)/mentor/projects/[id]/page.tsx +++ b/src/app/(mentor)/mentor/projects/[id]/page.tsx @@ -94,14 +94,18 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { }, }) + // TODO(PR8 Task 9): show co-mentors. For now we pick the first assignment + // to keep tracking + chat working unchanged. + const primaryAssignment = project?.mentorAssignments?.[0] ?? null + // Track view when project loads const trackView = trpc.mentor.trackView.useMutation() useEffect(() => { - if (project?.mentorAssignment?.id) { - trackView.mutate({ mentorAssignmentId: project.mentorAssignment.id }) + if (primaryAssignment?.id) { + trackView.mutate({ mentorAssignmentId: primaryAssignment.id }) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [project?.mentorAssignment?.id]) + }, [primaryAssignment?.id]) if (isLoading) { return @@ -135,7 +139,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD') const otherMembers = project.teamMembers?.filter((m) => m.role !== 'LEAD') || [] - const mentorAssignment = project.mentorAssignment + const mentorAssignment = primaryAssignment const mentorAssignmentId = mentorAssignment?.id const programId = project.program?.id const viewerIsAssignedMentor = @@ -477,7 +481,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { { await sendMessage.mutateAsync({ projectId, message }) }} diff --git a/src/server/routers/applicant.ts b/src/server/routers/applicant.ts index a21e54e..cb88368 100644 --- a/src/server/routers/applicant.ts +++ b/src/server/routers/applicant.ts @@ -1176,7 +1176,7 @@ export const applicantRouter = router({ ], }, include: { - mentorAssignment: { select: { mentorId: true } }, + mentorAssignments: { select: { mentorId: true } }, }, }) @@ -1187,7 +1187,10 @@ export const applicantRouter = router({ }) } - if (!project.mentorAssignment) { + // TODO(PR8 Task 7): notify ALL assigned mentors. For now we notify the + // first one for legacy parity. + const primaryMentorAssignment = project.mentorAssignments[0] ?? null + if (!primaryMentorAssignment) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'No mentor assigned to this project', @@ -1207,9 +1210,9 @@ export const applicantRouter = router({ }, }) - // Notify the mentor + // Notify the (primary) mentor await createNotification({ - userId: project.mentorAssignment.mentorId, + userId: primaryMentorAssignment.mentorId, type: 'MENTOR_MESSAGE', title: 'New Message', message: `${ctx.user.name || 'Team member'} sent a message about "${project.title}"`, @@ -1313,7 +1316,7 @@ export const applicantRouter = router({ submittedBy: { select: { id: true, name: true, email: true }, }, - mentorAssignment: { + mentorAssignments: { include: { mentor: { select: { id: true, name: true, email: true }, @@ -1523,7 +1526,7 @@ export const applicantRouter = router({ select: { id: true, programId: true, - mentorAssignment: { select: { id: true } }, + mentorAssignments: { select: { id: true }, take: 1 }, }, }) @@ -1531,8 +1534,8 @@ export const applicantRouter = router({ return { hasMentor: false, hasEvaluationRounds: false } } - // Check if mentor is assigned - const hasMentor = !!project.mentorAssignment + // Check if mentor is assigned (any active assignment counts) + const hasMentor = project.mentorAssignments.length > 0 // Check if feedback is available — first check admin settings, then fall back to per-round config let hasEvaluationRounds = false @@ -2689,8 +2692,12 @@ export const applicantRouter = router({ }) } - const assignment = await ctx.prisma.mentorAssignment.findUnique({ + // TODO(PR8 Task 7): when multiple mentors are assigned, surface them all + // in the applicant message thread. For now we display the most recently + // assigned (non-dropped) mentor as the "primary". + const assignment = await ctx.prisma.mentorAssignment.findFirst({ where: { projectId: input.projectId }, + orderBy: { assignedAt: 'desc' }, include: { mentor: { select: { id: true, name: true, email: true } } }, }) diff --git a/src/server/routers/finalist.ts b/src/server/routers/finalist.ts index 8834d0e..685fc3c 100644 --- a/src/server/routers/finalist.ts +++ b/src/server/routers/finalist.ts @@ -772,7 +772,8 @@ export const finalistRouter = router({ select: { id: true, title: true, - mentorAssignment: { + mentorAssignments: { + where: { droppedAt: null, completionStatus: { not: 'completed' } }, select: { id: true, completionStatus: true, @@ -796,10 +797,12 @@ export const finalistRouter = router({ data: { status: 'SUPERSEDED' }, }) - // Cascade: drop active mentor assignment (skip if completed or already dropped) - const ma = confirmation.project.mentorAssignment + // Cascade: drop ALL active mentor assignments (skip dropped/completed — + // those were filtered out by the include `where` above). With multi-mentor + // (PR8) we propagate the cascade to every active assignment. + const activeAssignments = confirmation.project.mentorAssignments let cascadedMentorAssignment = false - if (ma && !ma.droppedAt && ma.completionStatus !== 'completed') { + for (const ma of activeAssignments) { await ctx.prisma.mentorAssignment.update({ where: { id: ma.id }, data: { @@ -833,6 +836,7 @@ export const finalistRouter = router({ reason: input.reason, projectId: confirmation.projectId, cascadedMentorAssignment, + cascadedAssignmentCount: activeAssignments.length, }, }) return { ok: true, cascadedMentorAssignment } diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index f818aad..91d75ca 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -82,13 +82,15 @@ export const mentorRouter = router({ const project = await ctx.prisma.project.findUniqueOrThrow({ where: { id: input.projectId }, include: { - mentorAssignment: true, + mentorAssignments: true, }, }) - if (project.mentorAssignment) { + // TODO(PR8 Task 8): surface all mentors. Legacy single-mentor early-return. + const primaryMentor = project.mentorAssignments[0] ?? null + if (primaryMentor) { return { - currentMentor: project.mentorAssignment, + currentMentor: primaryMentor, suggestions: [], source: 'ai' as const, message: 'Project already has a mentor assigned', @@ -222,10 +224,10 @@ export const mentorRouter = router({ // Verify project exists and doesn't have a mentor const project = await ctx.prisma.project.findUniqueOrThrow({ where: { id: input.projectId }, - include: { mentorAssignment: true }, + include: { mentorAssignments: { select: { id: true } } }, }) - if (project.mentorAssignment) { + if (project.mentorAssignments.length > 0) { throw new TRPCError({ code: 'CONFLICT', message: 'Project already has a mentor assigned', @@ -351,13 +353,16 @@ export const mentorRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - // Verify project exists and doesn't have a mentor + // Verify project exists and doesn't already have a mentor. Multi-mentor + // stacking is reserved for explicit admin assignment via `mentor.assign`; + // auto-assignment skips projects that already have at least one mentor + // to avoid double-AI-assignments. const project = await ctx.prisma.project.findUniqueOrThrow({ where: { id: input.projectId }, - include: { mentorAssignment: true }, + include: { mentorAssignments: { select: { id: true } } }, }) - if (project.mentorAssignment) { + if (project.mentorAssignments.length > 0) { throw new TRPCError({ code: 'CONFLICT', message: 'Project already has a mentor assigned', @@ -490,8 +495,12 @@ export const mentorRouter = router({ unassign: adminProcedure .input(z.object({ projectId: z.string() })) .mutation(async ({ ctx, input }) => { - const assignment = await ctx.prisma.mentorAssignment.findUnique({ + // TODO(PR8 Task 8): admin UI should specify which mentor to drop when + // multiple are assigned. Legacy callers pass only projectId — we resolve + // to the most-recent assignment for backward compatibility. + const assignment = await ctx.prisma.mentorAssignment.findFirst({ where: { projectId: input.projectId }, + orderBy: { assignedAt: 'desc' }, include: { mentor: { select: { id: true, name: true } }, project: { select: { id: true, title: true } }, @@ -507,7 +516,7 @@ export const mentorRouter = router({ // Delete assignment await ctx.prisma.mentorAssignment.delete({ - where: { projectId: input.projectId }, + where: { id: assignment.id }, }) // Audit outside transaction so failures don't roll back the unassignment @@ -546,7 +555,7 @@ export const mentorRouter = router({ const projects = await ctx.prisma.project.findMany({ where: { programId: input.programId, - mentorAssignment: null, + mentorAssignments: { none: {} }, wantsMentorship: true, }, select: { id: true }, @@ -716,7 +725,7 @@ export const mentorRouter = router({ where: { roundId: input.roundId, project: { - mentorAssignment: null, + mentorAssignments: { none: {} }, // Only assign mentors to projects whose team has confirmed they will // attend the grand finale. This skips PENDING/DECLINED/EXPIRED/SUPERSEDED // confirmations and any project without a confirmation row at all. @@ -834,7 +843,7 @@ export const mentorRouter = router({ where: { roundId: input.roundId, project: { - mentorAssignment: { isNot: null }, + mentorAssignments: { some: {} }, ...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}), }, }, @@ -906,13 +915,13 @@ export const mentorRouter = router({ ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId, - project: { wantsMentorship: true, mentorAssignment: { isNot: null } }, + project: { wantsMentorship: true, mentorAssignments: { some: {} } }, }, }), ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId, - project: { mentorAssignment: { isNot: null } }, + project: { mentorAssignments: { some: {} } }, }, }), ctx.prisma.mentorMessage.count({ @@ -1107,7 +1116,11 @@ export const mentorRouter = router({ status: true, oceanIssue: true, competitionCategory: true, - mentorAssignment: { + mentorAssignments: { + // TODO(PR8 Task 8): surface all mentors in the activity view. + // For now keep the legacy single-mentor activity row by picking the + // latest-assigned, non-dropped assignment (or the most-recent overall). + orderBy: { assignedAt: 'desc' }, select: { id: true, method: true, @@ -1157,7 +1170,10 @@ export const mentorRouter = router({ const rows = projects.map((p) => { // Treat a dropped mentor assignment as if no mentor is assigned. - const ma = p.mentorAssignment && !p.mentorAssignment.droppedAt ? p.mentorAssignment : null + // TODO(PR8 Task 8): surface all mentors. Legacy shape: pick the most + // recent non-dropped assignment for the activity row. + const firstActive = p.mentorAssignments.find((a) => !a.droppedAt) ?? null + const ma = firstActive const lastMessageAt = ma?.messages[0]?.createdAt ?? null const lastFileAt = ma?.files[0]?.createdAt ?? null const lastActivityAt = [lastMessageAt, lastFileAt] @@ -1279,7 +1295,7 @@ export const mentorRouter = router({ files: { orderBy: { createdAt: 'desc' }, }, - mentorAssignment: { + mentorAssignments: { include: { mentor: { select: { id: true, name: true, email: true }, @@ -2157,6 +2173,12 @@ export const mentorRouter = router({ select: { bucket: true, objectKey: true, fileName: true, mentorAssignmentId: true }, }) if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' }) + // TODO(PR8 Task 5): re-scope workspace access from assignment to project + // so files whose original assignment was dropped (mentorAssignmentId = + // null) remain accessible by the team. + if (!file.mentorAssignmentId) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Orphaned workspace file' }) + } await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId) const url = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900, { downloadFileName: file.fileName }) @@ -2174,6 +2196,10 @@ export const mentorRouter = router({ select: { mentorAssignmentId: true }, }) if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' }) + // TODO(PR8 Task 5): re-scope workspace access from assignment to project. + if (!file.mentorAssignmentId) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Orphaned workspace file' }) + } await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId) try { await workspaceDeleteFileService( @@ -2209,6 +2235,10 @@ export const mentorRouter = router({ if (!file) { throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' }) } + // TODO(PR8 Task 5): re-scope workspace access from assignment to project. + if (!file.mentorAssignmentId) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Orphaned workspace file' }) + } await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId) return workspaceAddFileComment( { diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts index e39c598..4d5b859 100644 --- a/src/server/routers/project.ts +++ b/src/server/routers/project.ts @@ -188,7 +188,7 @@ export const projectRouter = router({ orClauses.push({ assignments: { some: { userId: ctx.user.id } } }) } if (userHasRole(ctx.user, 'MENTOR')) { - orClauses.push({ mentorAssignment: { mentorId: ctx.user.id } }) + orClauses.push({ mentorAssignments: { some: { mentorId: ctx.user.id } } }) } if (userHasRole(ctx.user, 'APPLICANT')) { orClauses.push({ teamMembers: { some: { userId: ctx.user.id } } }) @@ -511,7 +511,7 @@ export const projectRouter = router({ }, orderBy: { joinedAt: 'asc' }, }, - mentorAssignment: { + mentorAssignments: { include: { mentor: { select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true }, @@ -585,14 +585,18 @@ export const projectRouter = router({ })) ) - const mentorWithAvatar = project.mentorAssignment + // TODO(PR8 Task 8): surface all mentors. For now we keep the legacy + // single-mentor shape and just pick the first non-dropped assignment + // so the admin UI keeps rendering without changes. + const primaryAssignment = project.mentorAssignments[0] ?? null + const mentorWithAvatar = primaryAssignment ? { - ...project.mentorAssignment, + ...primaryAssignment, mentor: { - ...project.mentorAssignment.mentor, + ...primaryAssignment.mentor, avatarUrl: await getUserAvatarUrl( - project.mentorAssignment.mentor.profileImageKey, - project.mentorAssignment.mentor.profileImageProvider + primaryAssignment.mentor.profileImageKey, + primaryAssignment.mentor.profileImageProvider ), }, } @@ -1311,7 +1315,7 @@ export const projectRouter = router({ }, orderBy: { joinedAt: 'asc' }, }, - mentorAssignment: { + mentorAssignments: { include: { mentor: { select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true }, @@ -1448,18 +1452,21 @@ export const projectRouter = router({ } }) ), - projectRaw.mentorAssignment - ? (async () => ({ - ...projectRaw.mentorAssignment!, - mentor: { - ...projectRaw.mentorAssignment!.mentor, - avatarUrl: await getUserAvatarUrl( - projectRaw.mentorAssignment!.mentor.profileImageKey, - projectRaw.mentorAssignment!.mentor.profileImageProvider - ), - }, - }))() - : Promise.resolve(null), + // TODO(PR8 Task 8): surface all mentors. Legacy shape — pick the first. + (async () => { + const primaryMa = projectRaw.mentorAssignments[0] ?? null + if (!primaryMa) return null + return { + ...primaryMa, + mentor: { + ...primaryMa.mentor, + avatarUrl: await getUserAvatarUrl( + primaryMa.mentor.profileImageKey, + primaryMa.mentor.profileImageProvider + ), + }, + } + })(), ]) return { diff --git a/src/server/routers/round.ts b/src/server/routers/round.ts index 13522b1..1f5eb13 100644 --- a/src/server/routers/round.ts +++ b/src/server/routers/round.ts @@ -236,7 +236,7 @@ export const roundRouter = router({ where: { roundId: input.roundId, project: { - mentorAssignment: null, + mentorAssignments: { none: {} }, ...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}), }, }, diff --git a/src/server/services/smart-assignment.ts b/src/server/services/smart-assignment.ts index d2dacf2..abb0e67 100644 --- a/src/server/services/smart-assignment.ts +++ b/src/server/services/smart-assignment.ts @@ -670,7 +670,7 @@ export async function getMentorSuggestionsForProject( projectTags: { include: { tag: true }, }, - mentorAssignment: true, + mentorAssignments: true, }, }) @@ -714,7 +714,7 @@ export async function getMentorSuggestionsForProject( for (const mentor of mentors) { // Skip if already assigned to this project - if (project.mentorAssignment?.mentorId === mentor.id) { + if (project.mentorAssignments.some((ma) => ma.mentorId === mentor.id)) { continue } diff --git a/tests/unit/mentor-assignment-ux.test.ts b/tests/unit/mentor-assignment-ux.test.ts index d4fea22..a512a09 100644 --- a/tests/unit/mentor-assignment-ux.test.ts +++ b/tests/unit/mentor-assignment-ux.test.ts @@ -213,12 +213,12 @@ describe('mentor.autoAssignBulkForRound', () => { expect(result.assigned).toBe(1) - const requestedAssigned = await prisma.mentorAssignment.findUnique({ + const requestedAssigned = await prisma.mentorAssignment.findFirst({ where: { projectId: projWithRequest.id }, }) expect(requestedAssigned).not.toBeNull() - const skippedNotAssigned = await prisma.mentorAssignment.findUnique({ + const skippedNotAssigned = await prisma.mentorAssignment.findFirst({ where: { projectId: projWithoutRequest.id }, }) expect(skippedNotAssigned).toBeNull() @@ -291,7 +291,7 @@ describe('mentor.autoAssignBulkForRound', () => { expect(result.assigned).toBe(1) expect(result.skipped).toBe(1) - const stillExisting = await prisma.mentorAssignment.findUnique({ + const stillExisting = await prisma.mentorAssignment.findFirst({ where: { projectId: projAlreadyAssigned.id }, }) expect(stillExisting?.mentorId).toBe(existingMentor.id) // unchanged @@ -377,17 +377,17 @@ describe('mentor.autoAssignBulkForRound', () => { expect(result.assigned).toBe(1) - const confirmedAssigned = await prisma.mentorAssignment.findUnique({ + const confirmedAssigned = await prisma.mentorAssignment.findFirst({ where: { projectId: projConfirmed.id }, }) expect(confirmedAssigned).not.toBeNull() - const pendingAssigned = await prisma.mentorAssignment.findUnique({ + const pendingAssigned = await prisma.mentorAssignment.findFirst({ where: { projectId: projPending.id }, }) expect(pendingAssigned).toBeNull() - const noConfAssigned = await prisma.mentorAssignment.findUnique({ + const noConfAssigned = await prisma.mentorAssignment.findFirst({ where: { projectId: projNoConfirmation.id }, }) expect(noConfAssigned).toBeNull() diff --git a/tests/unit/mentor-round-stats.test.ts b/tests/unit/mentor-round-stats.test.ts index da9920e..4bbc4b4 100644 --- a/tests/unit/mentor-round-stats.test.ts +++ b/tests/unit/mentor-round-stats.test.ts @@ -92,6 +92,7 @@ describe('mentor.getRoundStats', () => { }) await prisma.mentorFile.create({ data: { + projectId: projReqAssigned.id, mentorAssignmentId: a1.id, uploadedByUserId: mentor.id, fileName: 'plan.pdf',