feat(mentor-workspace): re-scope files from assignment to project for team-wide visibility

- MentorFile.projectId is the new access boundary; mentorAssignmentId stays
  as informational audit FK (nullable).
- uploadFile derives projectId from the assignment; getFiles takes projectId
  directly; deleteFile/addFileComment auth checks any mentor on the project
  OR a project team member.
- HMAC upload token now binds to projectId (in addition to assignmentId).
- promoteFile reads file.projectId directly (no more mentorAssignment null
  navigation).
- Removes 3 placeholder NOT_FOUND guards added in Task 4.
This commit is contained in:
Matt
2026-05-22 16:53:07 +02:00
parent a5ad11a1b5
commit 3a1eb149b6
9 changed files with 133 additions and 55 deletions

View File

@@ -67,6 +67,42 @@ async function assertWorkspaceAccess(
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this workspace' })
}
/**
* Project-scoped workspace access check (PR8 multi-mentor).
*
* Allowed when the user is either:
* 1) currently assigned as a mentor on this project (droppedAt = null), OR
* 2) a team member of the project.
*
* Also requires at least one active mentor assignment for the project with
* workspaceEnabled = true — meaning the project actually has a live workspace.
* Throws TRPCError on failure. Returns nothing on success.
*/
async function assertProjectWorkspaceAccess(
prisma: PrismaClient,
userId: string,
projectId: string,
): Promise<void> {
const liveMentorAssignment = await prisma.mentorAssignment.findFirst({
where: { projectId, droppedAt: null, workspaceEnabled: true },
select: { id: true },
})
if (!liveMentorAssignment) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Workspace is not enabled' })
}
const mentorOnProject = await prisma.mentorAssignment.findFirst({
where: { projectId, mentorId: userId, droppedAt: null },
select: { id: true },
})
if (mentorOnProject) return
const teamMembership = await prisma.teamMember.findFirst({
where: { projectId, userId },
select: { id: true },
})
if (teamMembership) return
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this workspace' })
}
export const mentorRouter = router({
/**
* Get AI-suggested mentor matches for a project
@@ -2127,6 +2163,7 @@ export const mentorRouter = router({
const exp = Math.floor(Date.now() / 1000) + 3600
const uploadToken = signMentorUploadToken({
mentorAssignmentId: assignment.id,
projectId: assignment.projectId,
uploaderUserId: ctx.user.id,
fileName: input.fileName,
mimeType: input.mimeType,
@@ -2183,14 +2220,17 @@ export const mentorRouter = router({
}),
/**
* List files in a workspace. Authorized for the assigned mentor or any
* project team member.
* List files in a project's mentor workspace. Authorized for any mentor
* currently assigned to the project, or any team member of the project.
*
* Project-scoped (PR8): all co-mentors share one file list, and files
* survive even when an originating assignment is later dropped.
*/
workspaceGetFiles: protectedProcedure
.input(z.object({ mentorAssignmentId: z.string() }))
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, input.mentorAssignmentId)
return workspaceGetFilesService(input.mentorAssignmentId, ctx.prisma)
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, input.projectId)
return workspaceGetFilesService(input.projectId, ctx.prisma)
}),
/**
@@ -2201,37 +2241,29 @@ export const mentorRouter = router({
.mutation(async ({ ctx, input }) => {
const file = await ctx.prisma.mentorFile.findUnique({
where: { id: input.mentorFileId },
select: { bucket: true, objectKey: true, fileName: true, mentorAssignmentId: true },
select: { bucket: true, objectKey: true, fileName: true, projectId: 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)
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
const url = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900,
{ downloadFileName: file.fileName })
return { url }
}),
/**
* Delete a workspace file (uploader or assigned mentor only).
* Delete a workspace file. Authorized for the uploader, any mentor
* currently assigned to the file's project, or any team member of the
* file's project. Final auth check lives in the service.
*/
workspaceDeleteFile: protectedProcedure
.input(z.object({ mentorFileId: z.string() }))
.mutation(async ({ ctx, input }) => {
const file = await ctx.prisma.mentorFile.findUnique({
where: { id: input.mentorFileId },
select: { mentorAssignmentId: true },
select: { projectId: 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)
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
try {
await workspaceDeleteFileService(
{ mentorFileId: input.mentorFileId, userId: ctx.user.id },
@@ -2261,16 +2293,12 @@ export const mentorRouter = router({
.mutation(async ({ ctx, input }) => {
const file = await ctx.prisma.mentorFile.findUnique({
where: { id: input.mentorFileId },
select: { mentorAssignmentId: true },
select: { projectId: 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)
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
return workspaceAddFileComment(
{
mentorFileId: input.mentorFileId,