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:
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, mentorProcedure, adminProcedure, protectedProcedure } from '../trpc'
|
||||
import { MentorAssignmentMethod } from '@prisma/client'
|
||||
import { MentorAssignmentMethod, type PrismaClient } from '@prisma/client'
|
||||
import {
|
||||
getAIMentorSuggestions,
|
||||
getRoundRobinMentor,
|
||||
@@ -20,8 +20,49 @@ import {
|
||||
uploadFile as workspaceUploadFile,
|
||||
addFileComment as workspaceAddFileComment,
|
||||
promoteFile as workspacePromoteFile,
|
||||
getFiles as workspaceGetFilesService,
|
||||
deleteFile as workspaceDeleteFileService,
|
||||
} from '../services/mentor-workspace'
|
||||
import { triggerInProgressOnActivity } from '../services/round-engine'
|
||||
import {
|
||||
generateMentorObjectKey,
|
||||
getPresignedUrl,
|
||||
BUCKET_NAME,
|
||||
deleteObject,
|
||||
} from '@/lib/minio'
|
||||
import {
|
||||
signMentorUploadToken,
|
||||
verifyMentorUploadToken,
|
||||
} from '@/lib/mentor-upload-token'
|
||||
|
||||
/**
|
||||
* Throws TRPCError if the given user is neither the assigned mentor
|
||||
* nor a team member of the project linked to the assignment.
|
||||
* Returns the loaded MentorAssignment + Project on success.
|
||||
*/
|
||||
async function assertWorkspaceAccess(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
mentorAssignmentId: string,
|
||||
) {
|
||||
const assignment = await prisma.mentorAssignment.findUnique({
|
||||
where: { id: mentorAssignmentId },
|
||||
include: { project: { select: { id: true, title: true } } },
|
||||
})
|
||||
if (!assignment) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Mentor assignment not found' })
|
||||
}
|
||||
if (!assignment.workspaceEnabled) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Workspace is not enabled' })
|
||||
}
|
||||
if (assignment.mentorId === userId) return assignment
|
||||
const teamMembership = await prisma.teamMember.findFirst({
|
||||
where: { projectId: assignment.projectId, userId },
|
||||
select: { id: true },
|
||||
})
|
||||
if (teamMembership) return assignment
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this workspace' })
|
||||
}
|
||||
|
||||
export const mentorRouter = router({
|
||||
/**
|
||||
@@ -1372,36 +1413,139 @@ export const mentorRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Upload a file to a workspace
|
||||
* Issue a presigned upload URL + signed token for a mentor-workspace file.
|
||||
* The token binds the bucket, objectKey, and uploader so the client cannot
|
||||
* forge a path; workspaceUploadFile reads the token, never the
|
||||
* client-supplied path.
|
||||
*/
|
||||
workspaceUploadFile: mentorProcedure
|
||||
workspaceGetUploadUrl: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
mentorAssignmentId: z.string(),
|
||||
fileName: z.string().min(1).max(255),
|
||||
mimeType: z.string(),
|
||||
size: z.number().int().min(0),
|
||||
bucket: z.string(),
|
||||
objectKey: z.string(),
|
||||
mimeType: z.string().min(1).max(200),
|
||||
size: z.number().int().min(0).max(500 * 1024 * 1024),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const assignment = await assertWorkspaceAccess(
|
||||
ctx.prisma, ctx.user.id, input.mentorAssignmentId,
|
||||
)
|
||||
const objectKey = generateMentorObjectKey(assignment.project.title, input.fileName)
|
||||
const uploadUrl = await getPresignedUrl(BUCKET_NAME, objectKey, 'PUT', 3600)
|
||||
const exp = Math.floor(Date.now() / 1000) + 3600
|
||||
const uploadToken = signMentorUploadToken({
|
||||
mentorAssignmentId: assignment.id,
|
||||
uploaderUserId: ctx.user.id,
|
||||
fileName: input.fileName,
|
||||
mimeType: input.mimeType,
|
||||
size: input.size,
|
||||
bucket: BUCKET_NAME,
|
||||
objectKey,
|
||||
exp,
|
||||
})
|
||||
return { uploadUrl, uploadToken, bucket: BUCKET_NAME, objectKey }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Record a workspace file upload. Requires a valid uploadToken issued by
|
||||
* workspaceGetUploadUrl — the token contains the server-built bucket,
|
||||
* objectKey, and uploader binding. The client cannot pass a path directly.
|
||||
*/
|
||||
workspaceUploadFile: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
uploadToken: z.string(),
|
||||
description: z.string().max(2000).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
let payload
|
||||
try {
|
||||
payload = verifyMentorUploadToken(input.uploadToken)
|
||||
} catch (e) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: e instanceof Error ? e.message : 'Invalid upload token',
|
||||
})
|
||||
}
|
||||
if (payload.uploaderUserId !== ctx.user.id) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Upload token does not belong to the current user',
|
||||
})
|
||||
}
|
||||
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, payload.mentorAssignmentId)
|
||||
return workspaceUploadFile(
|
||||
{
|
||||
workspaceId: input.mentorAssignmentId,
|
||||
workspaceId: payload.mentorAssignmentId,
|
||||
uploadedByUserId: ctx.user.id,
|
||||
fileName: input.fileName,
|
||||
mimeType: input.mimeType,
|
||||
size: input.size,
|
||||
bucket: input.bucket,
|
||||
objectKey: input.objectKey,
|
||||
fileName: payload.fileName,
|
||||
mimeType: payload.mimeType,
|
||||
size: payload.size,
|
||||
bucket: payload.bucket,
|
||||
objectKey: payload.objectKey,
|
||||
description: input.description,
|
||||
},
|
||||
ctx.prisma,
|
||||
)
|
||||
}),
|
||||
|
||||
/**
|
||||
* List files in a workspace. Authorized for the assigned mentor or any
|
||||
* project team member.
|
||||
*/
|
||||
workspaceGetFiles: protectedProcedure
|
||||
.input(z.object({ mentorAssignmentId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, input.mentorAssignmentId)
|
||||
return workspaceGetFilesService(input.mentorAssignmentId, ctx.prisma)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Issue a short-lived presigned GET URL to download a workspace file.
|
||||
*/
|
||||
workspaceGetFileDownloadUrl: protectedProcedure
|
||||
.input(z.object({ mentorFileId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const file = await ctx.prisma.mentorFile.findUnique({
|
||||
where: { id: input.mentorFileId },
|
||||
select: { bucket: true, objectKey: true, fileName: true, mentorAssignmentId: true },
|
||||
})
|
||||
if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
|
||||
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId)
|
||||
const url = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900,
|
||||
{ downloadFileName: file.fileName })
|
||||
return { url }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a workspace file (uploader or assigned mentor only).
|
||||
*/
|
||||
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 },
|
||||
})
|
||||
if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
|
||||
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId)
|
||||
try {
|
||||
await workspaceDeleteFileService(
|
||||
{ mentorFileId: input.mentorFileId, userId: ctx.user.id },
|
||||
ctx.prisma,
|
||||
deleteObject,
|
||||
)
|
||||
} catch (e) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: e instanceof Error ? e.message : 'Delete failed',
|
||||
})
|
||||
}
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Add a comment to a workspace file
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user