diff --git a/docs-site/docs/api-reference/admin.md b/docs-site/docs/api-reference/admin.md index b6fb6071..3629a82d 100644 --- a/docs-site/docs/api-reference/admin.md +++ b/docs-site/docs/api-reference/admin.md @@ -149,7 +149,7 @@ GET /api/admin/settings "maxAttachmentsPerTicket": 20, "allowedImageTypes": ["image/jpeg", "image/png", "image/gif", "image/webp"], "allowedVideoTypes": ["video/mp4", "video/webm", "video/ogg", "video/quicktime"], - "allowedDocumentTypes": ["application/pdf", "text/plain", "text/csv"] + "allowedDocumentTypes": ["application/pdf", "text/plain", "text/csv", "application/json", "application/jsonl", "text/markdown"] } ``` diff --git a/docs-site/docs/api-reference/upload.md b/docs-site/docs/api-reference/upload.md index b989b718..c130de0b 100644 --- a/docs-site/docs/api-reference/upload.md +++ b/docs-site/docs/api-reference/upload.md @@ -26,7 +26,7 @@ GET /api/upload "allowedTypes": { "image": ["image/jpeg", "image/png", "image/gif", "image/webp"], "video": ["video/mp4", "video/webm", "video/ogg", "video/quicktime"], - "document": ["application/pdf", "text/plain", "text/csv"] + "document": ["application/pdf", "text/plain", "text/csv", "application/json", "application/jsonl", "text/markdown"] } } ``` diff --git a/docs-site/docs/user-guide/admin.md b/docs-site/docs/user-guide/admin.md index 2e1e9666..3d13b3b0 100644 --- a/docs-site/docs/user-guide/admin.md +++ b/docs-site/docs/user-guide/admin.md @@ -127,6 +127,9 @@ Control which file types can be uploaded: - Excel (`application/vnd.ms-excel`, `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`) - Text (`text/plain`) - CSV (`text/csv`) +- JSON (`application/json`) +- JSONL (`application/jsonl`) +- Markdown (`text/markdown`) :::note SVG files are blocked for security reasons (potential XSS via embedded scripts). diff --git a/mcp/src/api-client.ts b/mcp/src/api-client.ts index 470ad3d0..08c39071 100644 --- a/mcp/src/api-client.ts +++ b/mcp/src/api-client.ts @@ -631,3 +631,202 @@ export async function searchTickets(projectKey: string, query: string) { export async function getRepositoryConfig(projectKey: string) { return apiRequest('GET', `/api/projects/${projectKey}/repository`) } + +// ============================================================================ +// Upload API (multipart/form-data) +// ============================================================================ + +/** + * Make an authenticated multipart/form-data request to the PUNT API + */ +async function apiUploadRequest(path: string, formData: FormData): Promise> { + const apiKey = resolveApiKey() + if (!apiKey) { + const credPath = getCredentialsFilePath() + return { + error: + 'MCP credentials not configured. Either:\n' + + `1. Create credentials file at ${credPath}\n` + + '2. Set PUNT_API_KEY and PUNT_API_URL environment variables\n' + + 'See: https://github.com/your-org/punt#mcp-server for setup instructions', + } + } + + const baseUrl = resolveApiUrl() + const url = `${baseUrl}${path}` + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + // Do NOT set Content-Type - fetch auto-sets multipart boundary + 'X-MCP-API-Key': apiKey, + }, + body: formData, + }) + + if (!response.ok) { + const text = await response.text() + let errorMessage: string + try { + const errorJson = JSON.parse(text) + errorMessage = errorJson.error || errorJson.message || text + } catch { + errorMessage = text || `HTTP ${response.status}` + } + return { error: errorMessage } + } + + const text = await response.text() + if (!text) { + return { data: undefined as T } + } + + const data = JSON.parse(text) as T + return { data } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + return { error: `Upload request failed: ${message}` } + } +} + +// ============================================================================ +// Attachments API +// ============================================================================ + +export interface UploadedFileData { + id: string + filename: string + originalName: string + mimetype: string + size: number + url: string + category: string +} + +export interface UploadResponse { + success: boolean + files: UploadedFileData[] +} + +export interface AttachmentData { + id: string + filename: string + mimeType: string + size: number + url: string + purpose: string | null + sourceCommit: string | null + commitDirtyStatus: string | null + createdAt: string + ticketId: string + uploaderId: string | null +} + +export async function uploadFiles(formData: FormData) { + return apiUploadRequest('/api/upload', formData) +} + +export async function listAttachments(projectKey: string, ticketId: string) { + return apiRequest( + 'GET', + `/api/projects/${projectKey}/tickets/${ticketId}/attachments`, + ) +} + +export interface LinkAttachmentsInput { + attachments: Array<{ + filename: string + originalName: string + mimeType: string + size: number + url: string + purpose?: string | null + sourceCommit?: string | null + commitDirtyStatus?: string | null + }> +} + +export async function linkAttachments( + projectKey: string, + ticketId: string, + data: LinkAttachmentsInput, +) { + return apiRequest( + 'POST', + `/api/projects/${projectKey}/tickets/${ticketId}/attachments`, + data, + ) +} + +export async function deleteAttachment(projectKey: string, ticketId: string, attachmentId: string) { + return apiRequest<{ success: boolean }>( + 'DELETE', + `/api/projects/${projectKey}/tickets/${ticketId}/attachments/${attachmentId}`, + ) +} + +export async function updateAttachment( + projectKey: string, + ticketId: string, + attachmentId: string, + data: { + purpose?: string | null + sourceCommit?: string | null + commitDirtyStatus?: string | null + }, +) { + return apiRequest( + 'PATCH', + `/api/projects/${projectKey}/tickets/${ticketId}/attachments/${attachmentId}`, + data, + ) +} + +export async function downloadAttachment( + projectKey: string, + ticketId: string, + attachmentId: string, +): Promise> { + const apiKey = resolveApiKey() + if (!apiKey) { + const credPath = getCredentialsFilePath() + return { + error: + 'MCP credentials not configured. Either:\n' + + `1. Create credentials file at ${credPath}\n` + + '2. Set PUNT_API_KEY and PUNT_API_URL environment variables\n' + + 'See: https://github.com/your-org/punt#mcp-server for setup instructions', + } + } + + const baseUrl = resolveApiUrl() + const url = `${baseUrl}/api/projects/${projectKey}/tickets/${ticketId}/attachments/${attachmentId}/download` + + try { + const response = await fetch(url, { + method: 'GET', + headers: { + 'X-MCP-API-Key': apiKey, + }, + }) + + if (!response.ok) { + const text = await response.text() + let errorMessage: string + try { + const errorJson = JSON.parse(text) + errorMessage = errorJson.error || errorJson.message || text + } catch { + errorMessage = text || `HTTP ${response.status}` + } + return { error: errorMessage } + } + + const data = await response.arrayBuffer() + return { data } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + return { error: `API request failed: ${message}` } + } +} diff --git a/mcp/src/index.ts b/mcp/src/index.ts index c332cc5f..56067ec6 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -4,6 +4,7 @@ import './env.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { registerAttachmentTools } from './tools/attachments.js' import { registerColumnTools } from './tools/columns.js' import { registerLabelTools } from './tools/labels.js' import { registerMemberTools } from './tools/members.js' @@ -25,6 +26,7 @@ registerMemberTools(server) registerLabelTools(server) registerColumnTools(server) registerRepositoryTools(server) +registerAttachmentTools(server) // Connect via stdio const transport = new StdioServerTransport() diff --git a/mcp/src/tools/attachments.ts b/mcp/src/tools/attachments.ts new file mode 100644 index 00000000..74abe32d --- /dev/null +++ b/mcp/src/tools/attachments.ts @@ -0,0 +1,439 @@ +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { basename, dirname, extname, resolve } from 'node:path' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { z } from 'zod' +import { + deleteAttachment, + downloadAttachment, + linkAttachments, + listAttachments, + listTickets, + updateAttachment, + uploadFiles, +} from '../api-client.js' +import { + errorResponse, + escapeMarkdown, + formatAttachmentList, + formatFileSize, + parseTicketKey, + textResponse, +} from '../utils.js' + +/** + * Map of file extensions to MIME types. + * Matches the allowed types in the PUNT upload route. + * The server performs magic-byte validation, so this is a best-effort hint. + */ +const EXTENSION_TO_MIME: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.ogg': 'video/ogg', + '.mov': 'video/quicktime', + '.pdf': 'application/pdf', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.txt': 'text/plain', + '.csv': 'text/csv', + '.json': 'application/json', + '.jsonl': 'application/jsonl', + '.md': 'text/markdown', +} + +/** + * Detect MIME type from file extension. + * Returns null if the extension is not in the allowed list. + */ +function detectMimeType(filePath: string): string | null { + const ext = extname(filePath).toLowerCase() + return EXTENSION_TO_MIME[ext] ?? null +} + +const ATTACHMENT_PURPOSE_ENUM = z.enum([ + 'plan', + 'session_transcript', + 'screenshot', + 'reference', + 'other', +]) + +export function registerAttachmentTools(server: McpServer) { + server.tool( + 'add_attachment', + 'Upload a file from a local path and attach it to a ticket', + { + key: z.string().describe('Ticket key (e.g., PUNT-42)'), + filePath: z.string().describe('Absolute path to the local file to upload'), + purpose: ATTACHMENT_PURPOSE_ENUM.optional().describe( + 'Purpose of this attachment (plan, session_transcript, screenshot, reference, other)', + ), + sourceCommit: z + .string() + .optional() + .describe('Git commit hash the client had checked out when creating this attachment'), + commitDirtyStatus: z + .string() + .optional() + .describe('Output of git status if the working tree was dirty (null/omit if clean)'), + }, + async ({ key, filePath, purpose, sourceCommit, commitDirtyStatus }) => { + const parsed = parseTicketKey(key) + if (!parsed) { + return errorResponse(`Invalid ticket key format: ${key}. Expected format: PROJECT-123`) + } + + // Detect MIME type from extension + const mimeType = detectMimeType(filePath) + if (!mimeType) { + const ext = extname(filePath).toLowerCase() + const supportedExts = Object.keys(EXTENSION_TO_MIME).join(', ') + return errorResponse( + `Unsupported file type: ${ext || '(no extension)'}. Supported: ${supportedExts}`, + ) + } + + // Read the file + let fileBuffer: Uint8Array + let fileSize: number + try { + const fileStat = await stat(filePath) + if (!fileStat.isFile()) { + return errorResponse(`Not a file: ${filePath}`) + } + fileSize = fileStat.size + fileBuffer = new Uint8Array(await readFile(filePath)) + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + return errorResponse(`Cannot read file: ${message}`) + } + + // Resolve ticket ID + const ticketsResult = await listTickets(parsed.projectKey) + if (ticketsResult.error) { + return errorResponse(ticketsResult.error) + } + + const ticket = ticketsResult.data?.find((t) => t.number === parsed.number) + if (!ticket) { + return errorResponse(`Ticket not found: ${key}`) + } + + // Step 1: Upload the file via multipart/form-data + const fileName = basename(filePath) + const blob = new Blob([fileBuffer.buffer as ArrayBuffer], { type: mimeType }) + const file = new File([blob], fileName, { type: mimeType }) + const formData = new FormData() + formData.append('files', file) + + const uploadResult = await uploadFiles(formData) + if (uploadResult.error) { + return errorResponse(`Upload failed: ${uploadResult.error}`) + } + + const uploadedFiles = uploadResult.data?.files + if (!uploadedFiles || uploadedFiles.length === 0) { + return errorResponse('Upload succeeded but no file data was returned') + } + + const uploaded = uploadedFiles[0] + + // Step 2: Link the uploaded file to the ticket + const linkResult = await linkAttachments(parsed.projectKey, ticket.id, { + attachments: [ + { + filename: uploaded.filename, + originalName: uploaded.originalName, + mimeType: uploaded.mimetype, + size: uploaded.size, + url: uploaded.url, + purpose: purpose ?? null, + sourceCommit: sourceCommit ?? null, + commitDirtyStatus: commitDirtyStatus ?? null, + }, + ], + }) + + if (linkResult.error) { + return errorResponse(`Failed to link attachment to ticket: ${linkResult.error}`) + } + + const ticketKey = `${parsed.projectKey.toUpperCase()}-${parsed.number}` + const parts = [ + `Attached **${escapeMarkdown(fileName)}** (${formatFileSize(fileSize)}, ${mimeType}) to **${ticketKey}**`, + ] + if (purpose) parts.push(`Purpose: ${purpose}`) + if (sourceCommit) { + const shortHash = sourceCommit.slice(0, 7) + parts.push(`Commit: ${shortHash}${commitDirtyStatus ? ' (dirty)' : ''}`) + } + return textResponse(parts.join('\n')) + }, + ) + + server.tool( + 'list_attachments', + 'List all attachments for a ticket', + { + key: z.string().describe('Ticket key (e.g., PUNT-42)'), + }, + async ({ key }) => { + const parsed = parseTicketKey(key) + if (!parsed) { + return errorResponse(`Invalid ticket key format: ${key}. Expected format: PROJECT-123`) + } + + // Resolve ticket ID + const ticketsResult = await listTickets(parsed.projectKey) + if (ticketsResult.error) { + return errorResponse(ticketsResult.error) + } + + const ticket = ticketsResult.data?.find((t) => t.number === parsed.number) + if (!ticket) { + return errorResponse(`Ticket not found: ${key}`) + } + + const result = await listAttachments(parsed.projectKey, ticket.id) + if (result.error) { + return errorResponse(result.error) + } + + const ticketKey = `${parsed.projectKey.toUpperCase()}-${parsed.number}` + return textResponse(formatAttachmentList(result.data ?? [], ticketKey)) + }, + ) + + server.tool( + 'remove_attachment', + 'Remove an attachment from a ticket', + { + key: z.string().describe('Ticket key (e.g., PUNT-42)'), + attachmentId: z + .string() + .optional() + .describe('Attachment ID to remove (from list_attachments)'), + filename: z + .string() + .optional() + .describe('Filename to search for (if attachmentId not provided)'), + }, + async ({ key, attachmentId, filename }) => { + const parsed = parseTicketKey(key) + if (!parsed) { + return errorResponse(`Invalid ticket key format: ${key}. Expected format: PROJECT-123`) + } + + if (!attachmentId && !filename) { + return errorResponse('Either attachmentId or filename must be provided') + } + + // Resolve ticket ID + const ticketsResult = await listTickets(parsed.projectKey) + if (ticketsResult.error) { + return errorResponse(ticketsResult.error) + } + + const ticket = ticketsResult.data?.find((t) => t.number === parsed.number) + if (!ticket) { + return errorResponse(`Ticket not found: ${key}`) + } + + // If filename provided instead of ID, look up the attachment + let targetId = attachmentId + let targetFilename = filename + + if (!targetId) { + const listResult = await listAttachments(parsed.projectKey, ticket.id) + if (listResult.error) { + return errorResponse(listResult.error) + } + + const attachments = listResult.data ?? [] + const searchTerm = filename?.toLowerCase() ?? '' + const match = attachments.find((a) => a.filename.toLowerCase().includes(searchTerm)) + + if (!match) { + return errorResponse(`No attachment found matching filename: ${filename}`) + } + + targetId = match.id + targetFilename = match.filename + } + + const result = await deleteAttachment(parsed.projectKey, ticket.id, targetId as string) + if (result.error) { + return errorResponse(result.error) + } + + const ticketKey = `${parsed.projectKey.toUpperCase()}-${parsed.number}` + const displayName = targetFilename ? escapeMarkdown(targetFilename) : targetId + return textResponse(`Removed attachment **${displayName}** from **${ticketKey}**`) + }, + ) + + server.tool( + 'download_attachment', + 'Download an attachment from a ticket to a local file path', + { + key: z.string().describe('Ticket key (e.g., PUNT-42)'), + attachmentId: z + .string() + .optional() + .describe('Attachment ID to download (from list_attachments)'), + filename: z + .string() + .optional() + .describe('Filename to search for (if attachmentId not provided)'), + outputPath: z.string().describe('Absolute path where the file should be saved'), + }, + async ({ key, attachmentId, filename, outputPath }) => { + const parsed = parseTicketKey(key) + if (!parsed) { + return errorResponse(`Invalid ticket key format: ${key}. Expected format: PROJECT-123`) + } + + if (!attachmentId && !filename) { + return errorResponse('Either attachmentId or filename must be provided') + } + + // Resolve ticket ID + const ticketsResult = await listTickets(parsed.projectKey) + if (ticketsResult.error) { + return errorResponse(ticketsResult.error) + } + + const ticket = ticketsResult.data?.find((t) => t.number === parsed.number) + if (!ticket) { + return errorResponse(`Ticket not found: ${key}`) + } + + // Find the attachment (always need metadata for the response) + const listResult = await listAttachments(parsed.projectKey, ticket.id) + if (listResult.error) { + return errorResponse(listResult.error) + } + + const attachments = listResult.data ?? [] + let targetAttachment: (typeof attachments)[number] | undefined + + if (attachmentId) { + targetAttachment = attachments.find((a) => a.id === attachmentId) + if (!targetAttachment) { + return errorResponse(`Attachment not found with ID: ${attachmentId}`) + } + } else { + const searchTerm = filename?.toLowerCase() ?? '' + targetAttachment = attachments.find((a) => a.filename.toLowerCase().includes(searchTerm)) + if (!targetAttachment) { + return errorResponse(`No attachment found matching filename: ${filename}`) + } + } + + // Download the file content + const downloadResult = await downloadAttachment( + parsed.projectKey, + ticket.id, + targetAttachment.id, + ) + if (downloadResult.error) { + return errorResponse(`Download failed: ${downloadResult.error}`) + } + + if (!downloadResult.data) { + return errorResponse('Download succeeded but no data was returned') + } + + // Write to output path + const resolvedPath = resolve(outputPath) + try { + await mkdir(dirname(resolvedPath), { recursive: true }) + await writeFile(resolvedPath, Buffer.from(downloadResult.data)) + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + return errorResponse(`Failed to write file: ${message}`) + } + + const ticketKey = `${parsed.projectKey.toUpperCase()}-${parsed.number}` + return textResponse( + `Downloaded **${escapeMarkdown(targetAttachment.filename)}** (${formatFileSize(targetAttachment.size)}) from **${ticketKey}** to \`${escapeMarkdown(resolvedPath)}\``, + ) + }, + ) + + server.tool( + 'update_attachment', + 'Update the purpose or source commit metadata of an attachment on a ticket', + { + key: z.string().describe('Ticket key (e.g., PUNT-42)'), + attachmentId: z.string().describe('Attachment ID (from list_attachments)'), + purpose: ATTACHMENT_PURPOSE_ENUM.nullable() + .optional() + .describe('New purpose (null to clear)'), + sourceCommit: z.string().nullable().optional().describe('Git commit hash (null to clear)'), + commitDirtyStatus: z + .string() + .nullable() + .optional() + .describe('Git dirty status output (null to clear)'), + }, + async ({ key, attachmentId, purpose, sourceCommit, commitDirtyStatus }) => { + const parsed = parseTicketKey(key) + if (!parsed) { + return errorResponse(`Invalid ticket key format: ${key}. Expected format: PROJECT-123`) + } + + // Resolve ticket ID + const ticketsResult = await listTickets(parsed.projectKey) + if (ticketsResult.error) { + return errorResponse(ticketsResult.error) + } + + const ticket = ticketsResult.data?.find((t) => t.number === parsed.number) + if (!ticket) { + return errorResponse(`Ticket not found: ${key}`) + } + + const data: Record = {} + if (purpose !== undefined) data.purpose = purpose + if (sourceCommit !== undefined) data.sourceCommit = sourceCommit + if (commitDirtyStatus !== undefined) data.commitDirtyStatus = commitDirtyStatus + + if (Object.keys(data).length === 0) { + return errorResponse( + 'No fields to update. Provide purpose, sourceCommit, or commitDirtyStatus.', + ) + } + + const result = await updateAttachment( + parsed.projectKey, + ticket.id, + attachmentId, + data as { + purpose?: string | null + sourceCommit?: string | null + commitDirtyStatus?: string | null + }, + ) + if (result.error) { + return errorResponse(result.error) + } + + const ticketKey = `${parsed.projectKey.toUpperCase()}-${parsed.number}` + const updated = result.data + const parts = [`Updated attachment on **${ticketKey}**`] + if (updated?.purpose) parts.push(`Purpose: ${updated.purpose}`) + if (updated?.sourceCommit) { + const shortHash = updated.sourceCommit.slice(0, 7) + parts.push(`Commit: ${shortHash}${updated.commitDirtyStatus ? ' (dirty)' : ''}`) + } + return textResponse(parts.join('\n')) + }, + ) +} diff --git a/mcp/src/utils.ts b/mcp/src/utils.ts index 80fe9afb..11a279bd 100644 --- a/mcp/src/utils.ts +++ b/mcp/src/utils.ts @@ -376,6 +376,63 @@ export function formatSprintList( return lines.join('\n') } +/** + * Format a file size in bytes to a human-readable string + */ +export function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB'] + const k = 1024 + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), units.length - 1) + const value = bytes / k ** i + return `${value < 10 && i > 0 ? value.toFixed(1) : Math.round(value)} ${units[i]}` +} + +/** + * Format a list of attachments as a markdown table + */ +export function formatAttachmentList( + attachments: Array<{ + id: string + filename: string + mimeType: string + size: number + purpose?: string | null + sourceCommit?: string | null + commitDirtyStatus?: string | null + createdAt: string + }>, + ticketKey: string, +): string { + if (attachments.length === 0) { + return `No attachments on ${ticketKey}.` + } + + const lines: string[] = [] + lines.push(`## Attachments on ${ticketKey}`) + lines.push('') + lines.push('| ID | Filename | Type | Size | Purpose | Commit | Uploaded |') + lines.push('|----|----------|------|------|---------|--------|----------|') + + for (const att of attachments) { + const filename = safeTableCell(att.filename, 40) + const size = formatFileSize(att.size) + const purpose = att.purpose ?? '-' + const commit = att.sourceCommit + ? `${att.sourceCommit.slice(0, 7)}${att.commitDirtyStatus ? '\\*' : ''}` + : '-' + const uploaded = formatDate(att.createdAt) + lines.push( + `| ${att.id} | ${filename} | ${escapeTableCell(att.mimeType)} | ${size} | ${purpose} | ${commit} | ${uploaded} |`, + ) + } + + lines.push('') + lines.push(`Total: ${attachments.length} attachment(s)`) + + return lines.join('\n') +} + /** * Create a text response for MCP */ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 91cfea11..c3875a9c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -69,6 +69,14 @@ enum LinkType { is_duplicated_by } +enum AttachmentPurpose { + plan + session_transcript + screenshot + reference + other +} + // ============================================================================ // Models // ============================================================================ @@ -528,12 +536,15 @@ model Comment { } model Attachment { - id String @id @default(cuid()) - filename String - mimeType String - size Int // File size in bytes - url String // Storage path or URL - createdAt DateTime @default(now()) + id String @id @default(cuid()) + filename String + mimeType String + size Int // File size in bytes + url String // Storage path or URL + purpose AttachmentPurpose? + sourceCommit String? // Git commit hash at time of attachment + commitDirtyStatus String? // null if clean; git status output if dirty + createdAt DateTime @default(now()) ticketId String ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) @@ -607,7 +618,7 @@ model SystemSettings { // Allowed MIME types (native JSON arrays) allowedImageTypes Json @default("[\"image/jpeg\",\"image/png\",\"image/gif\",\"image/webp\"]") allowedVideoTypes Json @default("[\"video/mp4\",\"video/webm\",\"video/ogg\",\"video/quicktime\"]") - allowedDocumentTypes Json @default("[\"application/pdf\",\"application/msword\",\"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\"application/vnd.ms-excel\",\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\"text/plain\",\"text/csv\"]") + allowedDocumentTypes Json @default("[\"application/pdf\",\"application/msword\",\"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\"application/vnd.ms-excel\",\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\"text/plain\",\"text/csv\",\"application/json\",\"application/jsonl\",\"text/markdown\"]") // Email provider configuration emailEnabled Boolean @default(false) diff --git a/src/__tests__/fuzz/arbitraries/file-upload.ts b/src/__tests__/fuzz/arbitraries/file-upload.ts index 36b8beb5..06081886 100644 --- a/src/__tests__/fuzz/arbitraries/file-upload.ts +++ b/src/__tests__/fuzz/arbitraries/file-upload.ts @@ -34,6 +34,9 @@ export const allowedDocumentTypes = fc.constantFrom( 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/plain', 'text/csv', + 'application/json', + 'application/jsonl', + 'text/markdown', ) /** @@ -160,5 +163,8 @@ export const uploadSettings = fc.record({ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/plain', 'text/csv', + 'application/json', + 'application/jsonl', + 'text/markdown', ] as string[]), }) diff --git a/src/__tests__/fuzz/security/file-upload.fuzz.test.ts b/src/__tests__/fuzz/security/file-upload.fuzz.test.ts index 8168754e..5615142a 100644 --- a/src/__tests__/fuzz/security/file-upload.fuzz.test.ts +++ b/src/__tests__/fuzz/security/file-upload.fuzz.test.ts @@ -41,6 +41,9 @@ function createMockSettings(overrides: Partial = {}): SystemSett 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/plain', 'text/csv', + 'application/json', + 'application/jsonl', + 'text/markdown', ], showAddColumnButton: true, canonicalRepoUrl: null, diff --git a/src/app/api/projects/[projectId]/tickets/[ticketId]/attachments/[attachmentId]/download/route.ts b/src/app/api/projects/[projectId]/tickets/[ticketId]/attachments/[attachmentId]/download/route.ts new file mode 100644 index 00000000..e81dd1b0 --- /dev/null +++ b/src/app/api/projects/[projectId]/tickets/[ticketId]/attachments/[attachmentId]/download/route.ts @@ -0,0 +1,83 @@ +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import { join } from 'node:path' +import { handleApiError, notFoundError } from '@/lib/api-utils' +import { requireAuth, requireMembership, requireProjectByKey } from '@/lib/auth-helpers' +import { db } from '@/lib/db' + +/** + * GET /api/projects/[projectId]/tickets/[ticketId]/attachments/[attachmentId]/download + * Stream the attachment file content. + * Requires project membership. + */ +export async function GET( + _request: Request, + { params }: { params: Promise<{ projectId: string; ticketId: string; attachmentId: string }> }, +) { + try { + const user = await requireAuth() + const { projectId: projectKey, ticketId, attachmentId } = await params + const projectId = await requireProjectByKey(projectKey) + + await requireMembership(user.id, projectId) + + // Verify ticket exists and belongs to project + const ticket = await db.ticket.findFirst({ + where: { id: ticketId, projectId }, + select: { id: true }, + }) + + if (!ticket) { + return notFoundError('Ticket') + } + + // Find the attachment + const attachment = await db.attachment.findFirst({ + where: { id: attachmentId, ticketId }, + select: { id: true, filename: true, mimeType: true, size: true, url: true }, + }) + + if (!attachment) { + return notFoundError('Attachment') + } + + // Resolve file path from URL (e.g., "/uploads/file.png" → "public/uploads/file.png") + const filePath = join(process.cwd(), 'public', attachment.url) + + // Verify file exists on disk + try { + await stat(filePath) + } catch { + return notFoundError('Attachment file') + } + + // Stream the file + const stream = createReadStream(filePath) + const readableStream = new ReadableStream({ + start(controller) { + stream.on('data', (chunk: Buffer) => { + controller.enqueue(new Uint8Array(chunk)) + }) + stream.on('end', () => { + controller.close() + }) + stream.on('error', (err) => { + controller.error(err) + }) + }, + cancel() { + stream.destroy() + }, + }) + + return new Response(readableStream, { + headers: { + 'Content-Type': attachment.mimeType, + 'Content-Length': String(attachment.size), + 'Content-Disposition': `attachment; filename="${attachment.filename}"`, + }, + }) + } catch (error) { + return handleApiError(error, 'download attachment') + } +} diff --git a/src/app/api/projects/[projectId]/tickets/[ticketId]/attachments/[attachmentId]/route.ts b/src/app/api/projects/[projectId]/tickets/[ticketId]/attachments/[attachmentId]/route.ts index ddef420c..ed582c03 100644 --- a/src/app/api/projects/[projectId]/tickets/[ticketId]/attachments/[attachmentId]/route.ts +++ b/src/app/api/projects/[projectId]/tickets/[ticketId]/attachments/[attachmentId]/route.ts @@ -1,9 +1,87 @@ import { NextResponse } from 'next/server' -import { handleApiError, notFoundError } from '@/lib/api-utils' +import { z } from 'zod' +import { handleApiError, notFoundError, validationError } from '@/lib/api-utils' import { requireAttachmentPermission, requireAuth, requireProjectByKey } from '@/lib/auth-helpers' import { db } from '@/lib/db' import { projectEvents } from '@/lib/events' +const updateAttachmentSchema = z.object({ + purpose: z + .enum(['plan', 'session_transcript', 'screenshot', 'reference', 'other']) + .nullable() + .optional(), + sourceCommit: z.string().max(40).nullable().optional(), + commitDirtyStatus: z.string().nullable().optional(), +}) + +/** + * PATCH /api/projects/[projectId]/tickets/[ticketId]/attachments/[attachmentId] + * Update attachment metadata (purpose, sourceCommit, commitDirtyStatus). + * Requires ownership or attachments.manage_any permission. + */ +export async function PATCH( + request: Request, + { params }: { params: Promise<{ projectId: string; ticketId: string; attachmentId: string }> }, +) { + try { + const user = await requireAuth() + const { projectId: projectKey, ticketId, attachmentId } = await params + const projectId = await requireProjectByKey(projectKey) + + const ticket = await db.ticket.findFirst({ + where: { id: ticketId, projectId }, + select: { id: true }, + }) + + if (!ticket) { + return notFoundError('Ticket') + } + + const attachment = await db.attachment.findFirst({ + where: { id: attachmentId, ticketId }, + select: { id: true, uploaderId: true }, + }) + + if (!attachment) { + return notFoundError('Attachment') + } + + await requireAttachmentPermission(user.id, projectId, attachment.uploaderId, 'delete') + + const body = await request.json() + const parsed = updateAttachmentSchema.safeParse(body) + + if (!parsed.success) { + return validationError(parsed) + } + + const data: Record = {} + if (parsed.data.purpose !== undefined) data.purpose = parsed.data.purpose + if (parsed.data.sourceCommit !== undefined) data.sourceCommit = parsed.data.sourceCommit + if (parsed.data.commitDirtyStatus !== undefined) + data.commitDirtyStatus = parsed.data.commitDirtyStatus + + const updated = await db.attachment.update({ + where: { id: attachmentId }, + data, + }) + + const tabId = request.headers.get('X-Tab-Id') || undefined + projectEvents.emitTicketEvent({ + type: 'ticket.updated', + projectId, + ticketId, + userId: user.id, + tabId, + timestamp: Date.now(), + }) + + return NextResponse.json(updated) + } catch (error) { + return handleApiError(error, 'update attachment') + } +} + /** * DELETE /api/projects/[projectId]/tickets/[ticketId]/attachments/[attachmentId] * Remove an attachment record from the database. diff --git a/src/app/api/projects/[projectId]/tickets/[ticketId]/attachments/route.ts b/src/app/api/projects/[projectId]/tickets/[ticketId]/attachments/route.ts index d90b6d74..5e3b22b1 100644 --- a/src/app/api/projects/[projectId]/tickets/[ticketId]/attachments/route.ts +++ b/src/app/api/projects/[projectId]/tickets/[ticketId]/attachments/route.ts @@ -6,6 +6,14 @@ import { db } from '@/lib/db' import { projectEvents } from '@/lib/events' import { getSystemSettings } from '@/lib/system-settings' +const attachmentPurposeEnum = z.enum([ + 'plan', + 'session_transcript', + 'screenshot', + 'reference', + 'other', +]) + const addAttachmentsSchema = z.object({ attachments: z.array( z.object({ @@ -14,6 +22,9 @@ const addAttachmentsSchema = z.object({ mimeType: z.string().min(1), size: z.number().int().positive(), url: z.string().min(1), + purpose: attachmentPurposeEnum.nullish(), + sourceCommit: z.string().max(40).nullish(), + commitDirtyStatus: z.string().nullish(), }), ), }) @@ -113,6 +124,9 @@ export async function POST( mimeType: attachment.mimeType, size: attachment.size, url: attachment.url, + purpose: attachment.purpose ?? undefined, + sourceCommit: attachment.sourceCommit ?? undefined, + commitDirtyStatus: attachment.commitDirtyStatus ?? undefined, uploaderId: user.id, })), }) diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 38c2dada..f13cf61f 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -27,6 +27,9 @@ const SAFE_EXTENSIONS: Record = { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx', 'text/plain': 'txt', 'text/csv': 'csv', + 'application/json': 'json', + 'application/jsonl': 'jsonl', + 'text/markdown': 'md', } function generateFilename(originalName: string, mimeType: string): string { @@ -193,6 +196,9 @@ export async function GET() { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/plain', 'text/csv', + 'application/json', + 'application/jsonl', + 'text/markdown', ], maxSizes: { image: 10 * 1024 * 1024, diff --git a/src/components/admin/settings-form.tsx b/src/components/admin/settings-form.tsx index 74d4cdbf..7de98671 100644 --- a/src/components/admin/settings-form.tsx +++ b/src/components/admin/settings-form.tsx @@ -40,6 +40,9 @@ const ALL_DOCUMENT_TYPES = [ }, { value: 'text/plain', label: 'Plain Text (TXT)' }, { value: 'text/csv', label: 'CSV' }, + { value: 'application/json', label: 'JSON' }, + { value: 'application/jsonl', label: 'JSONL' }, + { value: 'text/markdown', label: 'Markdown' }, ] export function SettingsForm() { diff --git a/src/components/tickets/attachment-list.tsx b/src/components/tickets/attachment-list.tsx index 5ce65c93..5a05551d 100644 --- a/src/components/tickets/attachment-list.tsx +++ b/src/components/tickets/attachment-list.tsx @@ -57,6 +57,23 @@ function getFileIcon(category: 'image' | 'video' | 'document') { } } +const PURPOSE_LABELS: Record = { + plan: 'Plan', + session_transcript: 'Transcript', + screenshot: 'Screenshot', + reference: 'Reference', + other: 'Other', +} + +function formatPurpose(purpose: string): string { + return PURPOSE_LABELS[purpose] ?? purpose +} + +function formatCommitBadge(sourceCommit: string, commitDirtyStatus?: string | null): string { + const shortHash = sourceCommit.slice(0, 7) + return commitDirtyStatus ? `${shortHash}*` : shortHash +} + // Check if a file can be previewed in the modal function canPreview(file: UploadedFile): boolean { return ( @@ -204,13 +221,30 @@ export function AttachmentList({
-

- {file.originalName} -

-

{formatFileSize(file.size)}

+
+

+ {file.originalName} +

+ {file.purpose && ( + + {formatPurpose(file.purpose)} + + )} +
+
+

{formatFileSize(file.size)}

+ {file.sourceCommit && ( + + {formatCommitBadge(file.sourceCommit, file.commitDirtyStatus)} + + )} +
@@ -320,8 +354,25 @@ export function AttachmentList({ {/* File info */}
-

{file.originalName}

-

{formatFileSize(file.size)}

+
+

{file.originalName}

+ {file.purpose && ( + + {formatPurpose(file.purpose)} + + )} +
+
+

{formatFileSize(file.size)}

+ {file.sourceCommit && ( + + {formatCommitBadge(file.sourceCommit, file.commitDirtyStatus)} + + )} +
{/* Actions */} diff --git a/src/components/tickets/file-upload.tsx b/src/components/tickets/file-upload.tsx index f5e1a8b9..ddd521ca 100644 --- a/src/components/tickets/file-upload.tsx +++ b/src/components/tickets/file-upload.tsx @@ -14,6 +14,9 @@ export interface UploadedFile { size: number url: string category: 'image' | 'video' | 'document' + purpose?: string | null + sourceCommit?: string | null + commitDirtyStatus?: string | null } interface FileUploadProps { @@ -64,6 +67,9 @@ function mimeTypesToExtensions(types: string[]): string[] { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx', 'text/plain': '.txt', 'text/csv': '.csv', + 'application/json': '.json', + 'application/jsonl': '.jsonl', + 'text/markdown': '.md', } return types.map((type) => mimeToExt[type]).filter(Boolean) } diff --git a/src/components/tickets/ticket-detail-drawer.tsx b/src/components/tickets/ticket-detail-drawer.tsx index af0fc75c..cd6c9a5b 100644 --- a/src/components/tickets/ticket-detail-drawer.tsx +++ b/src/components/tickets/ticket-detail-drawer.tsx @@ -333,6 +333,9 @@ export function TicketDetailDrawer({ ticket, projectKey, onClose }: TicketDetail size: a.size, url: a.url, category: getMimeTypeCategory(a.mimeType), + purpose: a.purpose ?? null, + sourceCommit: a.sourceCommit ?? null, + commitDirtyStatus: a.commitDirtyStatus ?? null, })), ) // Reset attachments collapsed state based on preference @@ -1544,6 +1547,9 @@ export function TicketDetailDrawer({ ticket, projectKey, onClose }: TicketDetail size: a.size, url: a.url, category: getMimeTypeCategory(a.mimetype), + purpose: a.purpose ?? null, + sourceCommit: a.sourceCommit ?? null, + commitDirtyStatus: a.commitDirtyStatus ?? null, }))} onRemove={(fileId) => { const removed = tempAttachments.find((a) => a.id === fileId) @@ -1631,6 +1637,9 @@ export function TicketDetailDrawer({ ticket, projectKey, onClose }: TicketDetail size: a.size, url: a.url, category: getMimeTypeCategory(a.mimeType), + purpose: a.purpose ?? null, + sourceCommit: a.sourceCommit ?? null, + commitDirtyStatus: a.commitDirtyStatus ?? null, })) // Update local state with correct server IDs setTempAttachments((prev) => { diff --git a/src/hooks/queries/use-attachments.ts b/src/hooks/queries/use-attachments.ts index 1a45df85..9038d9d6 100644 --- a/src/hooks/queries/use-attachments.ts +++ b/src/hooks/queries/use-attachments.ts @@ -62,6 +62,9 @@ export interface AddAttachmentParams { mimeType: string size: number url: string + purpose?: string | null + sourceCommit?: string | null + commitDirtyStatus?: string | null } /** diff --git a/src/hooks/queries/use-system-settings.ts b/src/hooks/queries/use-system-settings.ts index 76502f8d..111c669e 100644 --- a/src/hooks/queries/use-system-settings.ts +++ b/src/hooks/queries/use-system-settings.ts @@ -35,6 +35,9 @@ const DEMO_SYSTEM_SETTINGS: CombinedSystemSettings = { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/plain', 'text/csv', + 'application/json', + 'application/jsonl', + 'text/markdown', ], // Branding settings appName: 'PUNT', diff --git a/src/lib/prisma-selects.ts b/src/lib/prisma-selects.ts index 906ef40c..b8f74db1 100644 --- a/src/lib/prisma-selects.ts +++ b/src/lib/prisma-selects.ts @@ -68,6 +68,9 @@ export const ATTACHMENT_SELECT = { mimeType: true, size: true, url: true, + purpose: true, + sourceCommit: true, + commitDirtyStatus: true, createdAt: true, } as const diff --git a/src/lib/schemas/database-export.ts b/src/lib/schemas/database-export.ts index eab4a464..a1cddd4e 100644 --- a/src/lib/schemas/database-export.ts +++ b/src/lib/schemas/database-export.ts @@ -249,6 +249,12 @@ export const AttachmentSchema = z.object({ mimeType: z.string(), size: z.number(), url: z.string(), + purpose: z + .enum(['plan', 'session_transcript', 'screenshot', 'reference', 'other']) + .nullable() + .optional(), + sourceCommit: z.string().nullable().optional(), + commitDirtyStatus: z.string().nullable().optional(), createdAt: z.string().datetime(), ticketId: z.string(), uploaderId: z.string().nullable(), diff --git a/src/lib/system-settings.ts b/src/lib/system-settings.ts index 1e050dd0..439f66ce 100644 --- a/src/lib/system-settings.ts +++ b/src/lib/system-settings.ts @@ -27,6 +27,9 @@ const DEFAULT_SETTINGS = { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/plain', 'text/csv', + 'application/json', + 'application/jsonl', + 'text/markdown', ], } diff --git a/src/stores/undo-store.ts b/src/stores/undo-store.ts index 0bcba6be..db9f70cb 100644 --- a/src/stores/undo-store.ts +++ b/src/stores/undo-store.ts @@ -38,6 +38,9 @@ interface AttachmentInfo { mimetype: string size: number url: string + purpose?: string | null + sourceCommit?: string | null + commitDirtyStatus?: string | null } interface AttachmentAction { diff --git a/src/types/index.ts b/src/types/index.ts index 10795263..4c3dcfd6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -202,6 +202,9 @@ export interface AttachmentInfo { mimeType: string size: number url: string + purpose: string | null + sourceCommit: string | null + commitDirtyStatus: string | null createdAt: Date } @@ -306,6 +309,9 @@ export interface UploadedFileInfo { size: number url: string category: 'image' | 'video' | 'document' + purpose?: string | null + sourceCommit?: string | null + commitDirtyStatus?: string | null } // Form data for creating/editing tickets