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:
@@ -139,8 +139,9 @@ export default function ApplicantMentorPage() {
|
||||
)}
|
||||
|
||||
{/* Files */}
|
||||
{primaryAssignment?.id && (
|
||||
{primaryAssignment?.id && projectId && (
|
||||
<WorkspaceFilesPanel
|
||||
projectId={projectId}
|
||||
mentorAssignmentId={primaryAssignment.id}
|
||||
asApplicant
|
||||
/>
|
||||
|
||||
@@ -104,7 +104,10 @@ export default function MentorWorkspaceDetailPage() {
|
||||
|
||||
<TabsContent value="files" className="mt-6">
|
||||
{assignment ? (
|
||||
<WorkspaceFilesPanel mentorAssignmentId={assignment.id} />
|
||||
<WorkspaceFilesPanel
|
||||
projectId={projectId}
|
||||
mentorAssignmentId={assignment.id}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
@@ -117,7 +120,7 @@ export default function MentorWorkspaceDetailPage() {
|
||||
|
||||
<TabsContent value="promotion" className="mt-6">
|
||||
{assignment ? (
|
||||
<FilePromotionPanel mentorAssignmentId={assignment.id} />
|
||||
<FilePromotionPanel projectId={projectId} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
|
||||
@@ -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<string>('')
|
||||
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({
|
||||
|
||||
@@ -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<HTMLInputElement>(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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user