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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user