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

@@ -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<void> {
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)