feat: mentor workspace files end-to-end with secure presign

Adds generateMentorObjectKey helper producing
<projectName>/mentorship/<timestamp>-<file>. Replaces the
client-supplied bucket/objectKey on workspaceUploadFile with an
HMAC-signed upload token that binds bucket, objectKey, uploader,
and a 1h expiry — paths can no longer be forged from the client.

Adds workspaceGetUploadUrl, workspaceGetFiles,
workspaceGetFileDownloadUrl, workspaceDeleteFile procedures with
mentor-or-team-member auth. Builds <WorkspaceFilesPanel> and
wires it into the mentor workspace Files tab and the applicant
/applicant/mentor page. Replaces the file-promotion-panel mock
array with a real workspaceGetFiles query.

Tests cover token sign/verify (5), key construction (5), and
end-to-end procedure flow including auth + tampered tokens (7).

Spec: docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md §F.1
Plan: docs/superpowers/plans/2026-04-28-pr2-mentor-workspace-files.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-28 13:33:18 +02:00
parent dd48db5eea
commit 2e7b545a1b
11 changed files with 699 additions and 30 deletions

View File

@@ -312,3 +312,47 @@ export async function promoteFile(
}
}
}
/**
* List files for a workspace, newest first, with comment counts and uploader.
*/
export async function getFiles(
workspaceId: string,
prisma: PrismaClient,
) {
return prisma.mentorFile.findMany({
where: { mentorAssignmentId: workspaceId },
orderBy: { createdAt: 'desc' },
include: {
uploadedBy: { select: { id: true, name: true, email: true } },
_count: { select: { comments: true } },
},
})
}
/**
* Delete a file. Caller must be either the uploader OR the assigned mentor.
* Removes the MinIO object and the DB row + cascade-deletes comments.
*/
export async function deleteFile(
params: { mentorFileId: string; userId: string },
prisma: PrismaClient,
removeStorageObject: (bucket: string, key: string) => Promise<void>,
): 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')
}
try {
await removeStorageObject(file.bucket, file.objectKey)
} catch (err) {
console.error('[mentor-workspace] failed to delete storage object', file.objectKey, err)
}
await prisma.mentorFile.delete({ where: { id: params.mentorFileId } })
}