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',