From 3a1eb149b66e2320b002e874b0b9ec88bf9d3c26 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 22 May 2026 16:53:07 +0200 Subject: [PATCH] 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. --- src/app/(applicant)/applicant/mentor/page.tsx | 3 +- .../mentor/workspace/[projectId]/page.tsx | 7 +- .../mentor/file-promotion-panel.tsx | 8 +- .../mentor/workspace-files-panel.tsx | 17 ++-- src/lib/mentor-upload-token.ts | 10 +++ src/server/routers/mentor.ts | 80 +++++++++++++------ src/server/services/mentor-workspace.ts | 55 +++++++++---- tests/unit/mentor-upload-token.test.ts | 1 + tests/unit/mentor-workspace-files.test.ts | 7 +- 9 files changed, 133 insertions(+), 55 deletions(-) diff --git a/src/app/(applicant)/applicant/mentor/page.tsx b/src/app/(applicant)/applicant/mentor/page.tsx index 4f35cf2..6a8d4a8 100644 --- a/src/app/(applicant)/applicant/mentor/page.tsx +++ b/src/app/(applicant)/applicant/mentor/page.tsx @@ -139,8 +139,9 @@ export default function ApplicantMentorPage() { )} {/* Files */} - {primaryAssignment?.id && ( + {primaryAssignment?.id && projectId && ( diff --git a/src/app/(mentor)/mentor/workspace/[projectId]/page.tsx b/src/app/(mentor)/mentor/workspace/[projectId]/page.tsx index dfc2473..ddc7a11 100644 --- a/src/app/(mentor)/mentor/workspace/[projectId]/page.tsx +++ b/src/app/(mentor)/mentor/workspace/[projectId]/page.tsx @@ -104,7 +104,10 @@ export default function MentorWorkspaceDetailPage() { {assignment ? ( - + ) : ( @@ -117,7 +120,7 @@ export default function MentorWorkspaceDetailPage() { {assignment ? ( - + ) : ( diff --git a/src/components/mentor/file-promotion-panel.tsx b/src/components/mentor/file-promotion-panel.tsx index 34cb1f4..a9526f4 100644 --- a/src/components/mentor/file-promotion-panel.tsx +++ b/src/components/mentor/file-promotion-panel.tsx @@ -17,7 +17,7 @@ import { FileText, Upload, CheckCircle2, ArrowUp } from 'lucide-react' import { toast } from 'sonner' interface FilePromotionPanelProps { - mentorAssignmentId: string + projectId: string } function formatFileSize(bytes: number): string { @@ -28,14 +28,14 @@ function formatFileSize(bytes: number): string { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] } -export function FilePromotionPanel({ mentorAssignmentId }: FilePromotionPanelProps) { +export function FilePromotionPanel({ projectId }: FilePromotionPanelProps) { const [selectedSlot, setSelectedSlot] = useState('') const utils = trpc.useUtils() const { data: workspaceFiles = [], isLoading: filesLoading } = trpc.mentor.workspaceGetFiles.useQuery( - { mentorAssignmentId }, - { enabled: !!mentorAssignmentId }, + { projectId }, + { enabled: !!projectId }, ) const promoteMutation = trpc.mentor.workspacePromoteFile.useMutation({ diff --git a/src/components/mentor/workspace-files-panel.tsx b/src/components/mentor/workspace-files-panel.tsx index fb8cd9a..d5cce29 100644 --- a/src/components/mentor/workspace-files-panel.tsx +++ b/src/components/mentor/workspace-files-panel.tsx @@ -16,6 +16,13 @@ import { FileText, Upload, Download, Trash2, MessageSquare } from 'lucide-react' import { formatDistanceToNow } from 'date-fns' interface Props { + /** Project the workspace belongs to — drives file list (project-scoped). */ + projectId: string + /** + * One MentorAssignment id on this project — needed only to mint upload tokens + * (the token is signed against the assignment + project pair, but the file + * itself is project-scoped so co-mentors see it). + */ mentorAssignmentId: string /** Set true on the applicant side to label uploads as "Team upload" — purely cosmetic. */ asApplicant?: boolean @@ -29,21 +36,21 @@ function formatSize(bytes: number): string { return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] } -export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props) { +export function WorkspaceFilesPanel({ projectId, mentorAssignmentId, asApplicant }: Props) { const utils = trpc.useUtils() const inputRef = useRef(null) const [uploading, setUploading] = useState(false) const [description, setDescription] = useState('') const { data: files, isLoading } = trpc.mentor.workspaceGetFiles.useQuery( - { mentorAssignmentId }, - { enabled: !!mentorAssignmentId } + { projectId }, + { enabled: !!projectId } ) const presign = trpc.mentor.workspaceGetUploadUrl.useMutation() const recordUpload = trpc.mentor.workspaceUploadFile.useMutation({ onSuccess: () => { - utils.mentor.workspaceGetFiles.invalidate({ mentorAssignmentId }) + utils.mentor.workspaceGetFiles.invalidate({ projectId }) setDescription('') toast.success('File uploaded') }, @@ -51,7 +58,7 @@ export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props) const downloadMutation = trpc.mentor.workspaceGetFileDownloadUrl.useMutation() const deleteMutation = trpc.mentor.workspaceDeleteFile.useMutation({ onSuccess: () => { - utils.mentor.workspaceGetFiles.invalidate({ mentorAssignmentId }) + utils.mentor.workspaceGetFiles.invalidate({ projectId }) toast.success('File deleted') }, onError: (e) => toast.error(e.message), diff --git a/src/lib/mentor-upload-token.ts b/src/lib/mentor-upload-token.ts index 06465e4..de74b07 100644 --- a/src/lib/mentor-upload-token.ts +++ b/src/lib/mentor-upload-token.ts @@ -2,6 +2,13 @@ import { createHmac, timingSafeEqual } from 'crypto' export type MentorUploadPayload = { mentorAssignmentId: string + /** + * Project the upload belongs to. Bound at token-issue time so the file's + * project scope can't be tampered with separately from the assignment id. + * Required (no legacy fallback) — tokens live <1h, so any in-flight tokens + * issued before this field was added expire on their own. + */ + projectId: string uploaderUserId: string fileName: string mimeType: string @@ -47,5 +54,8 @@ export function verifyMentorUploadToken(token: string): MentorUploadPayload { if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) { throw new Error('Invalid mentor upload token: expired') } + if (typeof payload.projectId !== 'string' || payload.projectId.length === 0) { + throw new Error('Invalid mentor upload token: missing projectId') + } return payload } diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index f55e74b..f39dc29 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -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 { + 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, diff --git a/src/server/services/mentor-workspace.ts b/src/server/services/mentor-workspace.ts index 73957d4..10fffa9 100644 --- a/src/server/services/mentor-workspace.ts +++ b/src/server/services/mentor-workspace.ts @@ -152,6 +152,11 @@ export async function markRead( /** * Record a file upload in a workspace. + * + * `workspaceId` is the originating MentorAssignment id (kept on the row as an + * audit-trail FK). We derive the project id from that assignment so the file + * is bound to the project — meaning any co-mentor on the project can see/use + * it, and the row survives if this particular assignment is later dropped. */ export async function uploadFile( params: { @@ -180,6 +185,7 @@ export async function uploadFile( return prisma.mentorFile.create({ data: { + projectId: assignment.projectId, mentorAssignmentId: params.workspaceId, uploadedByUserId: params.uploadedByUserId, fileName: params.fileName, @@ -238,9 +244,6 @@ export async function promoteFile( try { const file = await prisma.mentorFile.findUnique({ where: { id: params.mentorFileId }, - include: { - mentorAssignment: { select: { projectId: true } }, - }, }) if (!file) { @@ -265,7 +268,7 @@ export async function promoteFile( // Create promotion event await tx.submissionPromotionEvent.create({ data: { - projectId: file.mentorAssignment.projectId, + projectId: file.projectId, roundId: params.roundId, slotKey: params.slotKey, sourceType: 'MENTOR_FILE', @@ -281,7 +284,7 @@ export async function promoteFile( entityId: params.mentorFileId, actorId: params.promotedById, detailsJson: { - projectId: file.mentorAssignment.projectId, + projectId: file.projectId, roundId: params.roundId, slotKey: params.slotKey, fileName: file.fileName, @@ -297,7 +300,7 @@ export async function promoteFile( entityType: 'MentorFile', entityId: params.mentorFileId, detailsJson: { - projectId: file.mentorAssignment.projectId, + projectId: file.projectId, slotKey: params.slotKey, }, }) @@ -314,14 +317,17 @@ export async function promoteFile( } /** - * List files for a workspace, newest first, with comment counts and uploader. + * List files for a project, newest first, with comment counts and uploader. + * Project-scoped: every mentor assigned to the project (and every team member) + * sees the same file list, even if some files were uploaded under a now-dropped + * assignment. */ export async function getFiles( - workspaceId: string, + projectId: string, prisma: PrismaClient, ) { return prisma.mentorFile.findMany({ - where: { mentorAssignmentId: workspaceId }, + where: { projectId }, orderBy: { createdAt: 'desc' }, include: { uploadedBy: { select: { id: true, name: true, email: true } }, @@ -331,8 +337,10 @@ export async function getFiles( } /** - * Delete a file. Caller must be either the uploader OR the assigned mentor. - * Removes the MinIO object and the DB row + cascade-deletes comments. + * Delete a file. Caller must be either the uploader, OR any mentor currently + * assigned (not dropped) to the file's project, OR a team member of the + * file's project. Removes the MinIO object and the DB row + cascade-deletes + * comments. */ export async function deleteFile( params: { mentorFileId: string; userId: string }, @@ -341,13 +349,30 @@ export async function deleteFile( ): Promise { const file = await prisma.mentorFile.findUnique({ where: { id: params.mentorFileId }, - include: { mentorAssignment: { select: { mentorId: true } } }, }) if (!file) throw new Error('File not found') const isUploader = file.uploadedByUserId === params.userId - const isMentor = file.mentorAssignment.mentorId === params.userId - if (!isUploader && !isMentor) { - throw new Error('Only the uploader or the assigned mentor can delete this file') + let isAuthorized = isUploader + if (!isAuthorized) { + const mentorAssignment = await prisma.mentorAssignment.findFirst({ + where: { projectId: file.projectId, mentorId: params.userId, droppedAt: null }, + select: { id: true }, + }) + if (mentorAssignment) { + isAuthorized = true + } + } + if (!isAuthorized) { + const teamMembership = await prisma.teamMember.findFirst({ + where: { projectId: file.projectId, userId: params.userId }, + select: { id: true }, + }) + if (teamMembership) { + isAuthorized = true + } + } + if (!isAuthorized) { + throw new Error('Only the uploader, an assigned mentor, or a team member can delete this file') } try { await removeStorageObject(file.bucket, file.objectKey) diff --git a/tests/unit/mentor-upload-token.test.ts b/tests/unit/mentor-upload-token.test.ts index b6ef28f..b31c1ba 100644 --- a/tests/unit/mentor-upload-token.test.ts +++ b/tests/unit/mentor-upload-token.test.ts @@ -6,6 +6,7 @@ import { const samplePayload: MentorUploadPayload = { mentorAssignmentId: 'ma-123', + projectId: 'proj-789', uploaderUserId: 'user-456', fileName: 'doc.pdf', mimeType: 'application/pdf', diff --git a/tests/unit/mentor-workspace-files.test.ts b/tests/unit/mentor-workspace-files.test.ts index b38a8cc..ffc8e6b 100644 --- a/tests/unit/mentor-workspace-files.test.ts +++ b/tests/unit/mentor-workspace-files.test.ts @@ -8,6 +8,7 @@ import { signMentorUploadToken } from '../../src/lib/mentor-upload-token' describe('mentor.workspace files end-to-end', () => { let programId: string + let projectId: string let mentor: { id: string; email: string; role: 'MENTOR' } let outsider: { id: string; email: string; role: 'JURY_MEMBER' } let assignmentId: string @@ -18,6 +19,7 @@ describe('mentor.workspace files end-to-end', () => { const program = await createTestProgram({ name: `mentor-files-${uid()}` }) programId = program.id const project = await createTestProject(programId, { title: 'Test Project' }) + projectId = project.id const m = await createTestUser('MENTOR') userIds.push(m.id) @@ -79,6 +81,7 @@ describe('mentor.workspace files end-to-end', () => { it('rejects workspaceUploadFile with a token whose uploader differs from the caller', async () => { const forged = signMentorUploadToken({ mentorAssignmentId: assignmentId, + projectId, uploaderUserId: 'someone-else', fileName: 'x.pdf', mimeType: 'application/pdf', size: 1, bucket: 'mopc-files', objectKey: 'a/mentorship/0-x.pdf', @@ -94,7 +97,7 @@ describe('mentor.workspace files end-to-end', () => { mentorAssignmentId: assignmentId, fileName: 'b.pdf', mimeType: 'application/pdf', size: 50, }) await caller.workspaceUploadFile({ uploadToken: a.uploadToken }) - const files = await caller.workspaceGetFiles({ mentorAssignmentId: assignmentId }) + const files = await caller.workspaceGetFiles({ projectId }) expect(files.length).toBeGreaterThanOrEqual(2) expect(new Date(files[0].createdAt).getTime()).toBeGreaterThanOrEqual( new Date(files[1].createdAt).getTime(), @@ -104,7 +107,7 @@ describe('mentor.workspace files end-to-end', () => { it('refuses workspaceGetFiles to outsiders', async () => { const caller = createCaller(mentorRouter, outsider) await expect( - caller.workspaceGetFiles({ mentorAssignmentId: assignmentId }) + caller.workspaceGetFiles({ projectId }) ).rejects.toThrow(/FORBIDDEN|not a member/i) })