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)
})