# PR 2 — Mentor Workspace Files: secure backend + end-to-end UI (§F.1) > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make file sharing between mentors and project teams work end-to-end. Files land at `/mentorship/` in the configured MinIO bucket via a server-controlled path (no client-supplied bucket/objectKey). Mentors AND project team members can upload, list, download, comment, and delete files in a workspace. **Architecture:** Server-controlled object keys via a new `generateMentorObjectKey()` helper (mirrors existing `generateObjectKey()`). Upload flow: presign procedure issues an HMAC-signed token containing the server-built objectKey + bucket + assignmentId; client uses the presigned URL to PUT the file to MinIO; client then calls `workspaceUploadFile(token, description?)` which verifies the HMAC and writes the DB row. Token-binding eliminates client-controlled keys without adding new infrastructure (HMAC uses Node `crypto`, secret is `NEXTAUTH_SECRET`). UI: a shared `` component renders the list, upload, download, and comment surface for both mentor and applicant sides — wired into the existing mentor workspace Files tab and the existing applicant `/applicant/mentor` page. **Tech Stack:** TypeScript (strict), tRPC 11, Prisma 6, Node `crypto.createHmac`, existing `getPresignedUrl` from `src/lib/minio.ts`, shadcn/ui components, Vitest 4. **Spec:** `docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md` §F.1 (revised after walkthrough discovered upload UI is unimplemented). --- ## File map | File | Action | Responsibility | |------|--------|----------------| | `src/lib/minio.ts` | Modify | Export new `generateMentorObjectKey(projectTitle, fileName)` | | `src/lib/mentor-upload-token.ts` | Create | HMAC sign/verify helpers for upload tokens | | `src/server/services/mentor-workspace.ts` | Modify | Add `getFiles`, `getFileDownloadInfo`, `deleteFile`. `uploadFile` keeps its current signature (server already controls input). | | `src/server/routers/mentor.ts` | Modify | Add `workspaceGetUploadUrl`, `workspaceGetFiles`, `workspaceGetFileDownloadUrl`, `workspaceDeleteFile`. Replace `workspaceUploadFile` input schema with `{ uploadToken, description? }`. Broaden auth so project team members can also upload/list/comment/delete. | | `src/components/mentor/workspace-files-panel.tsx` | Create | Shared file-panel component (file list + upload + per-file download/delete + comment thread). Used by both mentor and applicant pages. | | `src/app/(mentor)/mentor/workspace/[projectId]/page.tsx` | Modify | Replace "coming soon" Files tab with `` | | `src/components/mentor/file-promotion-panel.tsx` | Modify | Replace mock `workspaceFiles: any[] = []` with real `mentor.workspaceGetFiles` query | | `src/app/(applicant)/applicant/mentor/page.tsx` | Modify | Add a Files section using `` | | `tests/unit/mentor-key-construction.test.ts` | Create | Verify `generateMentorObjectKey` shape + sanitization | | `tests/unit/mentor-upload-token.test.ts` | Create | Sign+verify roundtrip + tamper detection | | `tests/unit/mentor-workspace-files.test.ts` | Create | End-to-end procedure tests: presign → upload → list → download → comment → delete; auth checks; tampered token rejection | No Prisma migration. No new dependency. Existing `MentorFile` and `MentorFileComment` tables are sufficient. --- ## Task 1: Add `generateMentorObjectKey` helper **Files:** - Modify: `src/lib/minio.ts` (after the existing `generateObjectKey` function around line 150) - Create: `tests/unit/mentor-key-construction.test.ts` - [ ] **Step 1: Write the failing test** Create `tests/unit/mentor-key-construction.test.ts`: ```ts import { describe, expect, it } from 'vitest' import { generateMentorObjectKey } from '../../src/lib/minio' describe('generateMentorObjectKey', () => { it('produces a path under /mentorship/-', () => { const key = generateMentorObjectKey('Revamp Flips', 'meeting-notes.pdf') expect(key).toMatch(/^Revamp_Flips\/mentorship\/\d+-meeting-notes\.pdf$/) }) it('sanitizes special characters in the project title', () => { const key = generateMentorObjectKey('Côté & Bro 2026!', 'file.pdf') expect(key.startsWith('Ct_Bro_2026/mentorship/')).toBe(true) }) it('sanitizes special characters in the file name', () => { const key = generateMentorObjectKey('Project', 'rapport final 2026 — version 2.docx') expect(key).toMatch(/^Project\/mentorship\/\d+-rapport_final_2026___version_2\.docx$/) }) it('falls back to "unnamed" for an empty project title', () => { const key = generateMentorObjectKey('', 'doc.pdf') expect(key.startsWith('unnamed/mentorship/')).toBe(true) }) it('uses a different timestamp for sequential calls in different milliseconds', async () => { const a = generateMentorObjectKey('P', 'a.pdf') await new Promise((r) => setTimeout(r, 5)) const b = generateMentorObjectKey('P', 'a.pdf') expect(a).not.toEqual(b) }) }) ``` - [ ] **Step 2: Run the test, expect FAIL** Run: `cd /Users/matt/Repos/MOPC && npx vitest run tests/unit/mentor-key-construction.test.ts 2>&1 | tail -10` Expected: tests fail with `generateMentorObjectKey is not a function` or `Module has no exported member 'generateMentorObjectKey'`. - [ ] **Step 3: Add the helper to `src/lib/minio.ts`** Append after the `generateObjectKey` function (around line 150): ```ts /** * Generate a unique object key for a mentor-workspace file. * * Structure: {ProjectName}/mentorship/{timestamp}-{fileName} * Mirrors generateObjectKey but pins the round-name slot to "mentorship" * so all mentor workspace files for a project live under one folder. */ export function generateMentorObjectKey( projectTitle: string, fileName: string, ): string { return generateObjectKey(projectTitle, fileName, 'mentorship') } ``` - [ ] **Step 4: Re-run the test, expect PASS** Run: `cd /Users/matt/Repos/MOPC && npx vitest run tests/unit/mentor-key-construction.test.ts 2>&1 | tail -10` Expected: 5 passing. --- ## Task 2: Add HMAC upload-token helper **Files:** - Create: `src/lib/mentor-upload-token.ts` - Create: `tests/unit/mentor-upload-token.test.ts` - [ ] **Step 1: Write the failing test** Create `tests/unit/mentor-upload-token.test.ts`: ```ts import { describe, expect, it, beforeAll } from 'vitest' import { signMentorUploadToken, verifyMentorUploadToken, type MentorUploadPayload, } from '../../src/lib/mentor-upload-token' const samplePayload: MentorUploadPayload = { mentorAssignmentId: 'ma-123', uploaderUserId: 'user-456', fileName: 'doc.pdf', mimeType: 'application/pdf', size: 12345, bucket: 'mopc-files', objectKey: 'Project/mentorship/123-doc.pdf', exp: Math.floor(Date.now() / 1000) + 3600, } beforeAll(() => { process.env.NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET || 'test-secret-123' }) describe('mentor upload token', () => { it('round-trips a payload', () => { const token = signMentorUploadToken(samplePayload) expect(typeof token).toBe('string') expect(token.split('.').length).toBe(2) // payload.signature const verified = verifyMentorUploadToken(token) expect(verified.mentorAssignmentId).toBe('ma-123') expect(verified.objectKey).toBe('Project/mentorship/123-doc.pdf') }) it('rejects a tampered payload', () => { const token = signMentorUploadToken(samplePayload) const [, sig] = token.split('.') const tamperedPayload = Buffer.from(JSON.stringify({ ...samplePayload, size: 999999 })).toString('base64url') const tampered = `${tamperedPayload}.${sig}` expect(() => verifyMentorUploadToken(tampered)).toThrow(/signature/i) }) it('rejects a token with a different signature', () => { const token = signMentorUploadToken(samplePayload) const [payload] = token.split('.') const bad = `${payload}.0000000000000000000000000000000000000000000000000000000000000000` expect(() => verifyMentorUploadToken(bad)).toThrow(/signature/i) }) it('rejects an expired token', () => { const expired = signMentorUploadToken({ ...samplePayload, exp: Math.floor(Date.now() / 1000) - 60, }) expect(() => verifyMentorUploadToken(expired)).toThrow(/expired/i) }) it('rejects a malformed token', () => { expect(() => verifyMentorUploadToken('not-a-token')).toThrow() expect(() => verifyMentorUploadToken('one-segment')).toThrow() }) }) ``` - [ ] **Step 2: Run, expect FAIL** Run: `cd /Users/matt/Repos/MOPC && npx vitest run tests/unit/mentor-upload-token.test.ts 2>&1 | tail -8` Expected: failures because the module doesn't exist. - [ ] **Step 3: Create `src/lib/mentor-upload-token.ts`** ```ts import { createHmac, timingSafeEqual } from 'crypto' export type MentorUploadPayload = { mentorAssignmentId: string uploaderUserId: string fileName: string mimeType: string size: number bucket: string objectKey: string /** Unix seconds. Token is rejected after this. */ exp: number } function getSecret(): string { const s = process.env.NEXTAUTH_SECRET if (!s) throw new Error('NEXTAUTH_SECRET is not set; cannot sign mentor upload tokens') return s } function hmac(payloadB64: string): string { return createHmac('sha256', getSecret()).update(payloadB64).digest('hex') } export function signMentorUploadToken(payload: MentorUploadPayload): string { const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url') const sig = hmac(payloadB64) return `${payloadB64}.${sig}` } export function verifyMentorUploadToken(token: string): MentorUploadPayload { const parts = token.split('.') if (parts.length !== 2) throw new Error('Invalid mentor upload token: malformed') const [payloadB64, sig] = parts const expected = hmac(payloadB64) // timingSafeEqual requires equal-length buffers const a = Buffer.from(sig, 'hex') const b = Buffer.from(expected, 'hex') if (a.length !== b.length || !timingSafeEqual(a, b)) { throw new Error('Invalid mentor upload token: signature mismatch') } let payload: MentorUploadPayload try { payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf-8')) } catch { throw new Error('Invalid mentor upload token: payload not parseable') } if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) { throw new Error('Invalid mentor upload token: expired') } return payload } ``` - [ ] **Step 4: Re-run, expect PASS** Run: `cd /Users/matt/Repos/MOPC && npx vitest run tests/unit/mentor-upload-token.test.ts 2>&1 | tail -10` Expected: 5 passing. --- ## Task 3: Add `workspaceGetUploadUrl` procedure with auth + presign + token **Files:** - Modify: `src/server/routers/mentor.ts` — add procedure near the existing workspace section (around line 1377) - [ ] **Step 1: Read the existing imports and procedure around `workspaceUploadFile`** ```bash sed -n '1,30p' /Users/matt/Repos/MOPC/src/server/routers/mentor.ts ``` Confirm the file imports `protectedProcedure` (it does — line 3) and that `workspaceUploadFile` is currently `mentorProcedure`. We will: - Convert to `protectedProcedure` and check team-or-mentor membership inside. - Add `workspaceGetUploadUrl`, `workspaceGetFiles`, `workspaceGetFileDownloadUrl`, `workspaceDeleteFile`. - [ ] **Step 2: Add imports at the top of `src/server/routers/mentor.ts`** Append to the imports block at the top: ```ts import { generateMentorObjectKey, getPresignedUrl, BUCKET_NAME, deleteObject } from '@/lib/minio' import { signMentorUploadToken, verifyMentorUploadToken, } from '@/lib/mentor-upload-token' ``` (Confirm `deleteObject` is exported from `@/lib/minio`. If not, add it: it's a small wrapper on `minio.removeObject`. See `src/server/routers/file.ts:289` which already calls `deleteObject(bucket, key)`. Reuse the same export.) - [ ] **Step 3: Add a shared auth helper inside `mentorRouter` (right after the imports, before `mentorRouter = router({ ...`)** ```ts /** * Throws TRPCError UNAUTHORIZED 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' }) } ``` (Add `import type { PrismaClient } from '@prisma/client'` if not present.) - [ ] **Step 4: Add `workspaceGetUploadUrl` procedure inside `router({ ... })`** Insert immediately before the existing `workspaceUploadFile`: ```ts /** * 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; the upload-record procedure reads the token, never the * client-supplied path. */ workspaceGetUploadUrl: protectedProcedure .input( z.object({ mentorAssignmentId: z.string(), fileName: z.string().min(1).max(255), mimeType: z.string().min(1).max(200), size: z.number().int().min(0).max(500 * 1024 * 1024), // 500 MB cap }) ) .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 } }), ``` (`bucket` and `objectKey` are returned for client convenience/UI display — they're already in the signed token so they cannot be tampered with on the upload-record call.) --- ## Task 4: Replace `workspaceUploadFile` input schema with token-based **Files:** - Modify: `src/server/routers/mentor.ts` (replace the existing `workspaceUploadFile` definition around line 1377) - Modify: `src/server/services/mentor-workspace.ts` (no change needed — `uploadFile` keeps the same signature; only the procedure changes) - [ ] **Step 1: Replace the procedure body** Find the existing definition: ```ts workspaceUploadFile: mentorProcedure .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(), description: z.string().max(2000).optional(), }) ) .mutation(async ({ ctx, input }) => { return workspaceUploadFile(...) }), ``` Replace with: ```ts /** * 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', }) } // Defense-in-depth: re-check workspace access in case the assignment was // closed or the user removed from the team since presign. await assertWorkspaceAccess(ctx.prisma, ctx.user.id, payload.mentorAssignmentId) return workspaceUploadFile( { workspaceId: payload.mentorAssignmentId, uploadedByUserId: ctx.user.id, fileName: payload.fileName, mimeType: payload.mimeType, size: payload.size, bucket: payload.bucket, objectKey: payload.objectKey, description: input.description, }, ctx.prisma, ) }), ``` --- ## Task 5: Add `workspaceGetFiles`, `workspaceGetFileDownloadUrl`, `workspaceDeleteFile` procedures **Files:** - Modify: `src/server/services/mentor-workspace.ts` — add `getFiles` and `deleteFile` services - Modify: `src/server/routers/mentor.ts` — wire procedures - [ ] **Step 1: Add to `src/server/services/mentor-workspace.ts`** Append at the end of the file (before any closing brace if the file uses one): ```ts /** * 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, ): 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') } // Best-effort storage delete; DB delete is the source of truth. 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 } }) } ``` (Confirm `mentorFile` model has the relation `mentorAssignment`. If the relation is named differently, adjust. Schema search: `grep -A 20 "model MentorFile " prisma/schema.prisma`.) - [ ] **Step 2: Wire procedures in `src/server/routers/mentor.ts`** Add imports: ```ts import { // ... existing imports ... getFiles as workspaceGetFilesService, deleteFile as workspaceDeleteFileService, } from '../services/mentor-workspace' ``` Add procedures inside `router({ ... })` (right after `workspaceUploadFile`): ```ts /** * 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' }) // assertWorkspaceAccess guards membership; deleteFile guards uploader/mentor authority. 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 } }), ``` --- ## Task 6: Backend integration test **Files:** - Create: `tests/unit/mentor-workspace-files.test.ts` - [ ] **Step 1: Write the test (presign → upload → list → download → delete)** ```ts import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { prisma, createCaller } from '../setup' import { createTestUser, createTestProgram, createTestProject, cleanupTestData, uid, } from '../helpers' import { mentorRouter } from '../../src/server/routers/mentor' import { signMentorUploadToken } from '../../src/lib/mentor-upload-token' describe('mentor.workspace files end-to-end', () => { let programId: string let mentor: { id: string; email: string; role: 'MENTOR' } let outsider: { id: string; email: string; role: 'JURY_MEMBER' } let assignmentId: string const userIds: string[] = [] beforeAll(async () => { process.env.NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET || 'test-secret-123' const program = await createTestProgram({ name: `mentor-files-${uid()}` }) programId = program.id const project = await createTestProject(programId, { title: 'Test Project' }) const m = await createTestUser('MENTOR') userIds.push(m.id) mentor = { id: m.id, email: m.email, role: 'MENTOR' } const o = await createTestUser('JURY_MEMBER') userIds.push(o.id) outsider = { id: o.id, email: o.email, role: 'JURY_MEMBER' } const assignment = await prisma.mentorAssignment.create({ data: { id: uid('ma'), projectId: project.id, mentorId: m.id, method: 'MANUAL', workspaceEnabled: true, }, }) assignmentId = assignment.id }) afterAll(async () => { await cleanupTestData(programId, userIds) }) it('issues an upload URL + signed token to the assigned mentor', async () => { const caller = createCaller(mentorRouter, mentor) const result = await caller.workspaceGetUploadUrl({ mentorAssignmentId: assignmentId, fileName: 'plan.pdf', mimeType: 'application/pdf', size: 1024, }) expect(typeof result.uploadUrl).toBe('string') expect(result.uploadUrl).toContain('plan.pdf') expect(typeof result.uploadToken).toBe('string') expect(result.objectKey).toMatch(/^Test_Project\/mentorship\/\d+-plan\.pdf$/) }) it('rejects upload-url request from a user who is neither mentor nor team member', async () => { const caller = createCaller(mentorRouter, outsider) await expect( caller.workspaceGetUploadUrl({ mentorAssignmentId: assignmentId, fileName: 'x.pdf', mimeType: 'application/pdf', size: 1, }) ).rejects.toThrow(/not a member|FORBIDDEN/i) }) it('records a file when given a valid token', async () => { const caller = createCaller(mentorRouter, mentor) const presign = await caller.workspaceGetUploadUrl({ mentorAssignmentId: assignmentId, fileName: 'a.pdf', mimeType: 'application/pdf', size: 99, }) const file = await caller.workspaceUploadFile({ uploadToken: presign.uploadToken, description: 'first file', }) expect(file.fileName).toBe('a.pdf') expect(file.objectKey).toBe(presign.objectKey) expect(file.bucket).toBeTruthy() }) it('rejects workspaceUploadFile with a token whose uploader differs from the caller', async () => { const forged = signMentorUploadToken({ mentorAssignmentId: assignmentId, uploaderUserId: 'someone-else', fileName: 'x.pdf', mimeType: 'application/pdf', size: 1, bucket: 'mopc-files', objectKey: 'a/mentorship/0-x.pdf', exp: Math.floor(Date.now() / 1000) + 60, }) const caller = createCaller(mentorRouter, mentor) await expect(caller.workspaceUploadFile({ uploadToken: forged })).rejects.toThrow(/does not belong/i) }) it('lists files for the assigned mentor, sorted newest first', async () => { const caller = createCaller(mentorRouter, mentor) const a = await caller.workspaceGetUploadUrl({ mentorAssignmentId: assignmentId, fileName: 'b.pdf', mimeType: 'application/pdf', size: 50, }) await caller.workspaceUploadFile({ uploadToken: a.uploadToken }) const files = await caller.workspaceGetFiles({ mentorAssignmentId: assignmentId }) expect(files.length).toBeGreaterThanOrEqual(2) // newest first expect(new Date(files[0].createdAt).getTime()).toBeGreaterThanOrEqual( new Date(files[1].createdAt).getTime(), ) }) it('refuses workspaceGetFiles to outsiders', async () => { const caller = createCaller(mentorRouter, outsider) await expect( caller.workspaceGetFiles({ mentorAssignmentId: assignmentId }) ).rejects.toThrow(/FORBIDDEN|not a member/i) }) it('deletes a file the user uploaded', async () => { const caller = createCaller(mentorRouter, mentor) const p = await caller.workspaceGetUploadUrl({ mentorAssignmentId: assignmentId, fileName: 'kill.pdf', mimeType: 'application/pdf', size: 10, }) const file = await caller.workspaceUploadFile({ uploadToken: p.uploadToken }) const result = await caller.workspaceDeleteFile({ mentorFileId: file.id }) expect(result.success).toBe(true) const after = await prisma.mentorFile.findUnique({ where: { id: file.id } }) expect(after).toBeNull() }) }) ``` - [ ] **Step 2: Run tests, expect PASS** Run: `cd /Users/matt/Repos/MOPC && npx vitest run tests/unit/mentor-workspace-files.test.ts 2>&1 | tail -20` Expected: 7 passing. If any fail: read the failure carefully, fix the implementation. Common pitfall: the `MentorFile` schema may have a different relation name for `mentorAssignment` (verify with `grep "model MentorFile" -A 30 prisma/schema.prisma`). --- ## Task 7: Build shared `` component **Files:** - Create: `src/components/mentor/workspace-files-panel.tsx` - [ ] **Step 1: Create the component** ```tsx 'use client' import { useState, useRef } from 'react' import { trpc } from '@/lib/trpc/client' import { toast } from 'sonner' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { Input } from '@/components/ui/input' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog' import { FileText, Upload, Download, Trash2, MessageSquare } from 'lucide-react' import { formatDistanceToNow } from 'date-fns' interface Props { mentorAssignmentId: string /** Set true on the applicant side to label uploads as "Team upload" — purely cosmetic. */ asApplicant?: boolean } function formatSize(bytes: number): string { if (bytes === 0) return '0 B' const k = 1024 const sizes = ['B', 'KB', 'MB', 'GB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] } export function WorkspaceFilesPanel({ 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 } ) const presign = trpc.mentor.workspaceGetUploadUrl.useMutation() const recordUpload = trpc.mentor.workspaceUploadFile.useMutation({ onSuccess: () => { utils.mentor.workspaceGetFiles.invalidate({ mentorAssignmentId }) setDescription('') toast.success('File uploaded') }, }) const downloadMutation = trpc.mentor.workspaceGetFileDownloadUrl.useMutation() const deleteMutation = trpc.mentor.workspaceDeleteFile.useMutation({ onSuccess: () => { utils.mentor.workspaceGetFiles.invalidate({ mentorAssignmentId }) toast.success('File deleted') }, onError: (e) => toast.error(e.message), }) const handleFileSelected = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return e.target.value = '' // reset so same file can be picked again later setUploading(true) try { const { uploadUrl, uploadToken } = await presign.mutateAsync({ mentorAssignmentId, fileName: file.name, mimeType: file.type || 'application/octet-stream', size: file.size, }) const putRes = await fetch(uploadUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type || 'application/octet-stream' }, }) if (!putRes.ok) throw new Error(`Upload failed: HTTP ${putRes.status}`) await recordUpload.mutateAsync({ uploadToken, description: description || undefined }) } catch (err) { toast.error(err instanceof Error ? err.message : 'Upload failed') } finally { setUploading(false) } } const handleDownload = async (mentorFileId: string) => { try { const { url } = await downloadMutation.mutateAsync({ mentorFileId }) window.open(url, '_blank') } catch (err) { toast.error(err instanceof Error ? err.message : 'Download failed') } } if (isLoading) { return ( Workspace Files ) } return ( Workspace Files {asApplicant ? 'Files shared with your mentor in this workspace.' : 'Files you and the team have shared in this workspace.'}
setDescription(e.target.value)} placeholder="Optional description for the next upload" className="flex-1" />
{files && files.length === 0 && (
No files in this workspace yet.
)}
    {(files ?? []).map((f) => (
  • {f.fileName}
    {f.uploadedBy.name ?? f.uploadedBy.email} · {formatSize(f.size)} ·{' '} {formatDistanceToNow(new Date(f.createdAt), { addSuffix: true })} {f._count.comments > 0 && ( {f._count.comments} )}
    {f.description && (
    {f.description}
    )}
    Delete this file? This removes the file from MinIO and the workspace. Comments on the file are deleted with it. Cancel deleteMutation.mutate({ mentorFileId: f.id })}> Delete
  • ))}
) } ``` (If `date-fns` isn't installed: `grep date-fns package.json` — if absent, replace `formatDistanceToNow(...)` with a one-line relative-time util. Search for an existing one in `src/lib/utils.ts` first.) (If `@/components/ui/alert-dialog` isn't present: drop the dialog and use a plain `confirm()` for now. Verify with `ls src/components/ui/alert-dialog.tsx`.) --- ## Task 8: Wire mentor workspace + applicant page + file-promotion-panel **Files:** - Modify: `src/app/(mentor)/mentor/workspace/[projectId]/page.tsx` - Modify: `src/components/mentor/file-promotion-panel.tsx` - Modify: `src/app/(applicant)/applicant/mentor/page.tsx` - [ ] **Step 1: Mentor workspace Files tab** In `src/app/(mentor)/mentor/workspace/[projectId]/page.tsx`, add the import: ```tsx import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel' ``` Replace the `TabsContent value="files"` block (lines 104-119) with: ```tsx {assignment ? ( ) : (

Loading workspace…

)}
``` - [ ] **Step 2: file-promotion-panel — wire real query** In `src/components/mentor/file-promotion-panel.tsx`, replace the mock array (line ~36) with a real query: ```tsx // before const workspaceFiles: any[] = [] // Placeholder // after const { data: workspaceFiles = [] } = trpc.mentor.workspaceGetFiles.useQuery( { mentorAssignmentId }, { enabled: !!mentorAssignmentId } ) ``` (If the rest of the file currently treats `workspaceFiles` as items with `{ id, fileName, size, ... }`, the shape from the new query already matches.) - [ ] **Step 3: Applicant mentor page Files section** In `src/app/(applicant)/applicant/mentor/page.tsx`, find the existing layout (mentor info card + chat) and append a Files section. Read the file first: ```bash sed -n '1,50p' /Users/matt/Repos/MOPC/src/app/(applicant)/applicant/mentor/page.tsx ``` Add at top: ```tsx import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel' ``` In the JSX, after the chat / mentor info section, render: ```tsx {project?.mentorAssignment?.id && ( )} ``` (Confirm the field name during impl — `mentorAssignment` is exposed via the dashboard query; the mentor-page may use a different prop. Adjust to match.) --- ## Task 9: Run all checks + manual smoke + commit - [ ] **Step 1: Run unit + integration tests** ```bash cd /Users/matt/Repos/MOPC && npx vitest run tests/unit 2>&1 | tail -20 ``` Expected: all unit tests pass, including the 3 new test files (key construction, upload token, workspace files). - [ ] **Step 2: Typecheck** ```bash cd /Users/matt/Repos/MOPC && npm run typecheck 2>&1 | tail -10 ``` Expected: zero errors. - [ ] **Step 3: Build to surface client-side TS issues** ```bash cd /Users/matt/Repos/MOPC && npm run build 2>&1 | tail -25 ``` Expected: build succeeds. If it fails on a date-fns or alert-dialog import, swap to the fallback noted in Task 7. - [ ] **Step 4: Manual smoke (only if dev server running)** Sign in as `matt@monaco-opc.com` / `195260Mp!`, navigate to `/admin/projects//mentor`, assign mentor1@monaco-opc.com manually via Prisma Studio (or DB) since the manual picker UI isn't in this PR. Then sign in as mentor1 (set password via the script we used earlier), open `/mentor/workspace/`, open Files tab, upload a small file. Verify the file appears, downloads work, delete works. - [ ] **Step 5: Commit** ```bash cd /Users/matt/Repos/MOPC && git add \ src/lib/minio.ts \ src/lib/mentor-upload-token.ts \ src/server/routers/mentor.ts \ src/server/services/mentor-workspace.ts \ src/components/mentor/workspace-files-panel.tsx \ src/components/mentor/file-promotion-panel.tsx \ src/app/\(mentor\)/mentor/workspace/\[projectId\]/page.tsx \ src/app/\(applicant\)/applicant/mentor/page.tsx \ tests/unit/mentor-key-construction.test.ts \ tests/unit/mentor-upload-token.test.ts \ tests/unit/mentor-workspace-files.test.ts ``` Then: ```bash cd /Users/matt/Repos/MOPC && git commit -m "$(cat <<'EOF' feat: mentor workspace files end-to-end with secure presign Adds generateMentorObjectKey helper producing /mentorship/-. 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 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. 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) EOF )" ``` --- ## Acceptance criteria - [ ] All three new test files pass; full unit suite has no regressions. - [ ] `npm run typecheck` and `npm run build` succeed. - [ ] Backend rejects: tampered tokens, expired tokens, mismatched-uploader tokens, presign requests from non-members. - [ ] Manual smoke: file uploads via the mentor Files tab; same file appears in `/applicant/mentor`; delete works from either side as appropriate. - [ ] All paths in MinIO start with `/mentorship/`. ## Out of scope - File comment threads UI (DB model and a procedure exist but the panel doesn't render comments yet — leave as a follow-up). - Multipart / resumable uploads (single PUT for now; size cap 500 MB enforced server-side). - Per-file access tokens for download (using server presigned GET, 15 min expiry). - Migration of legacy MentorFile rows (verified no rows exist via prisma queries during walkthrough).