1089 lines
41 KiB
Markdown
1089 lines
41 KiB
Markdown
|
|
# 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 `<projectName>/mentorship/<file>` 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 `<WorkspaceFilesPanel>` 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 `<WorkspaceFilesPanel>` |
|
||
|
|
| `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 `<WorkspaceFilesPanel>` |
|
||
|
|
| `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 <projectName>/mentorship/<timestamp>-<file>', () => {
|
||
|
|
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<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')
|
||
|
|
}
|
||
|
|
// 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 `<WorkspaceFilesPanel>` 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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||
|
|
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 (
|
||
|
|
<Card>
|
||
|
|
<CardHeader><CardTitle>Workspace Files</CardTitle></CardHeader>
|
||
|
|
<CardContent className="space-y-2">
|
||
|
|
<Skeleton className="h-12 w-full" />
|
||
|
|
<Skeleton className="h-12 w-full" />
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle>Workspace Files</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
{asApplicant
|
||
|
|
? 'Files shared with your mentor in this workspace.'
|
||
|
|
: 'Files you and the team have shared in this workspace.'}
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-4">
|
||
|
|
<div className="flex flex-col sm:flex-row gap-2">
|
||
|
|
<Input
|
||
|
|
value={description}
|
||
|
|
onChange={(e) => setDescription(e.target.value)}
|
||
|
|
placeholder="Optional description for the next upload"
|
||
|
|
className="flex-1"
|
||
|
|
/>
|
||
|
|
<Button
|
||
|
|
disabled={uploading}
|
||
|
|
onClick={() => inputRef.current?.click()}
|
||
|
|
className="shrink-0"
|
||
|
|
>
|
||
|
|
<Upload className="mr-2 h-4 w-4" />
|
||
|
|
{uploading ? 'Uploading…' : 'Upload file'}
|
||
|
|
</Button>
|
||
|
|
<input
|
||
|
|
ref={inputRef} type="file" hidden onChange={handleFileSelected}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{files && files.length === 0 && (
|
||
|
|
<div className="text-center py-8 text-sm text-muted-foreground">
|
||
|
|
<FileText className="h-10 w-10 mx-auto mb-2 opacity-40" />
|
||
|
|
No files in this workspace yet.
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<ul className="divide-y">
|
||
|
|
{(files ?? []).map((f) => (
|
||
|
|
<li key={f.id} className="flex items-center gap-3 py-3">
|
||
|
|
<FileText className="h-5 w-5 text-muted-foreground shrink-0" />
|
||
|
|
<div className="flex-1 min-w-0">
|
||
|
|
<div className="font-medium truncate">{f.fileName}</div>
|
||
|
|
<div className="text-xs text-muted-foreground">
|
||
|
|
{f.uploadedBy.name ?? f.uploadedBy.email} · {formatSize(f.size)} ·{' '}
|
||
|
|
{formatDistanceToNow(new Date(f.createdAt), { addSuffix: true })}
|
||
|
|
{f._count.comments > 0 && (
|
||
|
|
<span className="ml-2 inline-flex items-center gap-1">
|
||
|
|
<MessageSquare className="h-3 w-3" />
|
||
|
|
{f._count.comments}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
{f.description && (
|
||
|
|
<div className="text-xs text-muted-foreground mt-1">{f.description}</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<Button variant="ghost" size="icon" onClick={() => handleDownload(f.id)}>
|
||
|
|
<Download className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
<AlertDialog>
|
||
|
|
<AlertDialogTrigger asChild>
|
||
|
|
<Button variant="ghost" size="icon" className="text-destructive hover:text-destructive">
|
||
|
|
<Trash2 className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
</AlertDialogTrigger>
|
||
|
|
<AlertDialogContent>
|
||
|
|
<AlertDialogHeader>
|
||
|
|
<AlertDialogTitle>Delete this file?</AlertDialogTitle>
|
||
|
|
<AlertDialogDescription>
|
||
|
|
This removes the file from MinIO and the workspace. Comments on the file are deleted with it.
|
||
|
|
</AlertDialogDescription>
|
||
|
|
</AlertDialogHeader>
|
||
|
|
<AlertDialogFooter>
|
||
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||
|
|
<AlertDialogAction onClick={() => deleteMutation.mutate({ mentorFileId: f.id })}>
|
||
|
|
Delete
|
||
|
|
</AlertDialogAction>
|
||
|
|
</AlertDialogFooter>
|
||
|
|
</AlertDialogContent>
|
||
|
|
</AlertDialog>
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
(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
|
||
|
|
<TabsContent value="files" className="mt-6">
|
||
|
|
{assignment ? (
|
||
|
|
<WorkspaceFilesPanel mentorAssignmentId={assignment.id} />
|
||
|
|
) : (
|
||
|
|
<Card>
|
||
|
|
<CardContent className="text-center py-8">
|
||
|
|
<FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
||
|
|
<p className="text-sm text-muted-foreground">Loading workspace…</p>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
</TabsContent>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **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 && (
|
||
|
|
<WorkspaceFilesPanel mentorAssignmentId={project.mentorAssignment.id} asApplicant />
|
||
|
|
)}
|
||
|
|
```
|
||
|
|
|
||
|
|
(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/<any project>/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/<projectId>`, 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
|
||
|
|
<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.
|
||
|
|
|
||
|
|
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>
|
||
|
|
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 `<projectName>/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).
|