refactor(schema-cascade): rename Project.mentorAssignment → mentorAssignments

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) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-05-22 16:37:37 +02:00
parent 9152ebb399
commit 66110598a0
11 changed files with 127 additions and 71 deletions

View File

@@ -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(
{