From ae5a90d8d454189963dfef02dbeb93a21c019509 Mon Sep 17 00:00:00 2001 From: bunker-admin Date: Sat, 11 Apr 2026 10:20:54 -0600 Subject: [PATCH] Add Document model upload + download routes (PDFs as first-class media) Documents are a separate media type from Video/Photo because the Photo pipeline assumes raster images (sharp metadata, EXIF, variant generation). The new routes mirror the photo upload pattern but target the Document Prisma model and serve files with Content-Disposition: attachment so browsers download instead of inline-rendering. Tag-based categorization (e.g. 'volunteer-resource') lets the volunteer dashboard surface curated downloads alongside videos and photos. Admin Library page gets a Documents tab for upload/list/edit/delete with the same affordances as the existing photo and video tabs. Bunker Admin --- admin/src/pages/media/DocumentsTab.tsx | 412 ++++++++++++++++++ admin/src/pages/media/LibraryPage.tsx | 24 +- api/src/media-server.ts | 6 + .../media/routes/document-upload.routes.ts | 133 ++++++ .../modules/media/routes/documents.routes.ts | 186 ++++++++ 5 files changed, 755 insertions(+), 6 deletions(-) create mode 100644 admin/src/pages/media/DocumentsTab.tsx create mode 100644 api/src/modules/media/routes/document-upload.routes.ts create mode 100644 api/src/modules/media/routes/documents.routes.ts diff --git a/admin/src/pages/media/DocumentsTab.tsx b/admin/src/pages/media/DocumentsTab.tsx new file mode 100644 index 00000000..0dc660f8 --- /dev/null +++ b/admin/src/pages/media/DocumentsTab.tsx @@ -0,0 +1,412 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + Table, + Button, + Upload, + Modal, + Form, + Input, + Select, + Tag, + Space, + message, + Tooltip, + Switch, + Empty, +} from 'antd'; +import type { UploadProps, UploadFile } from 'antd/es/upload/interface'; +import type { ColumnsType } from 'antd/es/table'; +import { + UploadOutlined, + DeleteOutlined, + EditOutlined, + LinkOutlined, + FilePdfOutlined, + FileTextOutlined, + FileExcelOutlined, + FileWordOutlined, + FileZipOutlined, + FileOutlined, +} from '@ant-design/icons'; +import { mediaApi } from '@/lib/media-api'; +import { getErrorMessage } from '@/utils/getErrorMessage'; + +interface DocumentRow { + id: string; + path: string; + filename: string; + originalFilename: string | null; + title: string | null; + description: string | null; + mimeType: string; + fileSize: string | null; + pageCount: number | null; + thumbnailPath: string | null; + category: string | null; + tags: string[] | null; + isPublished: boolean; + position: number | null; + downloadCount: number; + createdAt: string; + updatedAt: string; +} + +const TAG_SUGGESTIONS = ['volunteer-resource', 'training', 'handout', 'policy']; + +function formatBytes(raw: string | null | undefined): string { + if (!raw) return '—'; + const bytes = Number(raw); + if (!Number.isFinite(bytes) || bytes <= 0) return '—'; + const units = ['B', 'KB', 'MB', 'GB']; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`; +} + +function iconForMime(mime: string) { + if (mime.includes('pdf')) return ; + if (mime.includes('word')) return ; + if (mime.includes('sheet') || mime.includes('excel') || mime.includes('csv')) { + return ; + } + if (mime.includes('zip')) return ; + if (mime.startsWith('text/')) return ; + return ; +} + +function normalizeTags(raw: unknown): string[] { + if (!raw) return []; + if (Array.isArray(raw)) return raw.filter((t): t is string => typeof t === 'string'); + return []; +} + +export default function DocumentsTab() { + const [documents, setDocuments] = useState([]); + const [loading, setLoading] = useState(false); + const [uploadModalOpen, setUploadModalOpen] = useState(false); + const [uploading, setUploading] = useState(false); + const [editingDoc, setEditingDoc] = useState(null); + const [uploadForm] = Form.useForm(); + const [editForm] = Form.useForm(); + const pendingFile = useRef(null); + + const fetchDocuments = useCallback(async () => { + setLoading(true); + try { + const { data } = await mediaApi.get<{ items: DocumentRow[] }>('/documents', { + params: { published: 'false' }, + }); + const items = (data.items || []).map((d) => ({ + ...d, + tags: normalizeTags(d.tags), + })); + setDocuments(items); + } catch (error: unknown) { + message.error(getErrorMessage(error, 'Failed to load documents')); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchDocuments(); + }, [fetchDocuments]); + + const handleUpload = async () => { + if (!pendingFile.current) { + message.warning('Please select a file first'); + return; + } + + try { + const values = await uploadForm.validateFields(); + setUploading(true); + + const formData = new FormData(); + formData.append('file', pendingFile.current); + if (values.title) formData.append('title', values.title); + if (values.tags && values.tags.length > 0) { + formData.append('tags', JSON.stringify(values.tags)); + } + if (values.category) formData.append('category', values.category); + if (values.description) formData.append('description', values.description); + + await mediaApi.post('/documents/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + + message.success('Document uploaded'); + setUploadModalOpen(false); + uploadForm.resetFields(); + pendingFile.current = null; + await fetchDocuments(); + } catch (error: unknown) { + if (error instanceof Error && 'errorFields' in error) return; + message.error(getErrorMessage(error, 'Upload failed')); + } finally { + setUploading(false); + } + }; + + const handleEdit = (doc: DocumentRow) => { + setEditingDoc(doc); + editForm.setFieldsValue({ + title: doc.title || '', + description: doc.description || '', + tags: doc.tags || [], + category: doc.category || '', + isPublished: doc.isPublished, + position: doc.position ?? 0, + }); + }; + + const handleSaveEdit = async () => { + if (!editingDoc) return; + try { + const values = await editForm.validateFields(); + await mediaApi.put(`/documents/${editingDoc.id}`, values); + message.success('Document updated'); + setEditingDoc(null); + editForm.resetFields(); + await fetchDocuments(); + } catch (error: unknown) { + if (error instanceof Error && 'errorFields' in error) return; + message.error(getErrorMessage(error, 'Update failed')); + } + }; + + const handleDelete = (doc: DocumentRow) => { + Modal.confirm({ + title: 'Delete this document?', + content: doc.title || doc.originalFilename || doc.filename, + okText: 'Delete', + okType: 'danger', + onOk: async () => { + try { + await mediaApi.delete(`/documents/${doc.id}`); + message.success('Document deleted'); + await fetchDocuments(); + } catch (error: unknown) { + message.error(getErrorMessage(error, 'Delete failed')); + } + }, + }); + }; + + const handleCopyLink = (doc: DocumentRow) => { + const url = `${window.location.origin}/media/api/documents/${doc.id}/download`; + navigator.clipboard.writeText(url).then( + () => message.success('Download URL copied to clipboard'), + () => message.error('Failed to copy URL'), + ); + }; + + const uploadProps: UploadProps = { + beforeUpload: (file) => { + pendingFile.current = file; + if (!uploadForm.getFieldValue('title')) { + uploadForm.setFieldsValue({ title: file.name }); + } + return false; + }, + onRemove: () => { + pendingFile.current = null; + }, + maxCount: 1, + fileList: pendingFile.current + ? [{ uid: '-1', name: pendingFile.current.name, status: 'done' } as UploadFile] + : [], + }; + + const columns: ColumnsType = [ + { + title: '', + dataIndex: 'mimeType', + width: 56, + render: (mime: string) => iconForMime(mime), + }, + { + title: 'Title', + dataIndex: 'title', + render: (_: unknown, row) => ( +
+
{row.title || row.originalFilename || row.filename}
+
+ {row.originalFilename || row.filename} +
+
+ ), + }, + { + title: 'Size', + dataIndex: 'fileSize', + width: 90, + render: (size: string | null) => formatBytes(size), + }, + { + title: 'Type', + dataIndex: 'mimeType', + width: 140, + render: (mime: string) => {mime}, + }, + { + title: 'Tags', + dataIndex: 'tags', + render: (tags: string[] | null) => + tags && tags.length > 0 ? ( + + {tags.map((t) => ( + + {t} + + ))} + + ) : ( + + ), + }, + { + title: 'Downloads', + dataIndex: 'downloadCount', + width: 100, + align: 'right', + }, + { + title: 'Status', + dataIndex: 'isPublished', + width: 100, + render: (published: boolean) => + published ? Published : Draft, + }, + { + title: 'Actions', + key: 'actions', + width: 150, + render: (_: unknown, row) => ( + + + + + + + rowKey="id" + columns={columns} + dataSource={documents} + loading={loading} + pagination={{ pageSize: 50, showSizeChanger: true, pageSizeOptions: [25, 50, 100] }} + locale={{ emptyText: }} + /> + + { + if (uploading) return; + setUploadModalOpen(false); + uploadForm.resetFields(); + pendingFile.current = null; + }} + onOk={handleUpload} + confirmLoading={uploading} + okText="Upload" + destroyOnClose + > +
+ + +

+ +

+

Click or drag file to upload

+

+ PDF, DOC/DOCX, XLSX, CSV, TXT, MD, ZIP +

+
+
+ + + + + + + + + +
+
+ + { + setEditingDoc(null); + editForm.resetFields(); + }} + onOk={handleSaveEdit} + okText="Save" + destroyOnClose + > +
+ + + + + + + + + + + + + + + +
+
+ + ); +} diff --git a/admin/src/pages/media/LibraryPage.tsx b/admin/src/pages/media/LibraryPage.tsx index cbcc5f35..85981956 100644 --- a/admin/src/pages/media/LibraryPage.tsx +++ b/admin/src/pages/media/LibraryPage.tsx @@ -16,6 +16,7 @@ import { VideoCameraOutlined, FolderOutlined, FolderAddOutlined, + FileTextOutlined, } from '@ant-design/icons'; import { useOutletContext } from 'react-router-dom'; import { mediaApi } from '@/lib/media-api'; @@ -44,10 +45,11 @@ import FetchVideosDrawer from '@/components/media/FetchVideosDrawer'; import AddToPlaylistModal from '@/components/media/AddToPlaylistModal'; import BulkAddToPlaylistModal from '@/components/media/BulkAddToPlaylistModal'; import BulkAccessLevelModal from '@/components/media/BulkAccessLevelModal'; +import DocumentsTab from '@/pages/media/DocumentsTab'; import { getErrorMessage } from '@/utils/getErrorMessage'; import { PageTour } from '@/components/tour/PageTour'; -type MediaTab = 'Videos' | 'Photos' | 'Albums'; +type MediaTab = 'Videos' | 'Photos' | 'Albums' | 'Documents'; export default function LibraryPage() { const { setPageHeader } = useOutletContext(); @@ -117,7 +119,7 @@ export default function LibraryPage() { useEffect(() => { if (mediaTab === 'Videos') fetchVideos(); else if (mediaTab === 'Photos') fetchPhotos(); - else fetchAlbums(); + else if (mediaTab === 'Albums') fetchAlbums(); }, [mediaTab, debouncedSearch, orientation, selectedProducers, shortsFilter, formatFilter, videoPagination.page, videoPagination.limit, photoPagination.page, photoPagination.limit, @@ -364,11 +366,16 @@ export default function LibraryPage() { { value: 'Videos', icon: , label: 'Videos' }, { value: 'Photos', icon: , label: 'Photos' }, { value: 'Albums', icon: , label: 'Albums' }, + { value: 'Documents', icon: , label: 'Documents' }, ]} /> - {/* Toolbar */} + {/* Documents tab owns its own layout */} + {mediaTab === 'Documents' && } + + {/* Toolbar — hidden for Documents tab */} + {mediaTab !== 'Documents' && (
)}
+ )} - {/* Stats */} + {/* Stats (hidden for Documents) */} + {mediaTab !== 'Documents' && (
{activePagination.total} {mediaTab.toLowerCase().replace(/s$/, '')}{activePagination.total !== 1 ? 's' : ''} {mediaTab === 'Videos' && selectedVideoIds.length > 0 && ` · ${selectedVideoIds.length} selected`} {mediaTab === 'Photos' && selectedPhotoIds.length > 0 && ` · ${selectedPhotoIds.length} selected`}
+ )} - {/* Content Grid */} - {loading ? ( + {/* Content Grid (hidden for Documents) */} + {mediaTab !== 'Documents' && ( + loading ? (
@@ -594,6 +605,7 @@ export default function LibraryPage() { /> )} + ) )} {/* === Video Bulk Actions === */} diff --git a/api/src/media-server.ts b/api/src/media-server.ts index ad484095..66ac00b1 100644 --- a/api/src/media-server.ts +++ b/api/src/media-server.ts @@ -30,6 +30,8 @@ import { photoUploadRoutes } from './modules/media/routes/photo-upload.routes'; import { photoAlbumsRoutes } from './modules/media/routes/photo-albums.routes'; import { photosPublicRoutes } from './modules/media/routes/photos-public.routes'; import { photoEngagementRoutes } from './modules/media/routes/photo-engagement.routes'; +import { documentUploadRoutes } from './modules/media/routes/document-upload.routes'; +import { documentsRoutes } from './modules/media/routes/documents.routes'; import { mediaErrorHandler } from './modules/media/middleware/error-handler'; // Add BigInt serialization support for Prisma BigInt fields @@ -156,6 +158,10 @@ const start = async () => { await fastify.register(photosPublicRoutes, { prefix: '/api' }); await fastify.register(photoEngagementRoutes, { prefix: '/api' }); + // Document routes (PDFs, docx, etc. for volunteer resources) + await fastify.register(documentUploadRoutes, { prefix: '/api/documents' }); + await fastify.register(documentsRoutes, { prefix: '/api/documents' }); + // 404 handler for unmatched routes fastify.setNotFoundHandler((_request, reply) => { reply.status(404).send({ error: { message: 'Route not found', code: 'NOT_FOUND' } }); diff --git a/api/src/modules/media/routes/document-upload.routes.ts b/api/src/modules/media/routes/document-upload.routes.ts new file mode 100644 index 00000000..de221423 --- /dev/null +++ b/api/src/modules/media/routes/document-upload.routes.ts @@ -0,0 +1,133 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { pipeline } from 'stream/promises'; +import { createWriteStream } from 'fs'; +import { unlink, mkdir, stat } from 'fs/promises'; +import { join, extname } from 'path'; +import { randomUUID } from 'crypto'; +import { Prisma } from '@prisma/client'; +import { prisma } from '../../../config/database'; +import { requireAdminRole } from '../middleware/auth'; +import { logger } from '../../../utils/logger'; + +const DOCUMENT_INBOX_DIR = '/media/local/documents/inbox'; + +const ALLOWED_DOCUMENT_EXTENSIONS = [ + '.pdf', + '.doc', + '.docx', + '.xlsx', + '.csv', + '.txt', + '.md', + '.zip', +]; + +const ALLOWED_DOCUMENT_MIMETYPES = new Set([ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'text/csv', + 'application/csv', + 'text/plain', + 'text/markdown', + 'application/zip', + 'application/x-zip-compressed', + 'application/octet-stream', +]); + +function parseTags(raw: string | undefined): string[] | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.every((t) => typeof t === 'string')) { + return parsed; + } + } catch { + // Fall through: allow plain comma-separated fallback + } + const split = raw.split(',').map((t) => t.trim()).filter(Boolean); + return split.length > 0 ? split : null; +} + +export async function documentUploadRoutes(fastify: FastifyInstance) { + fastify.post( + '/upload', + { preHandler: requireAdminRole }, + async (request: FastifyRequest, reply: FastifyReply) => { + let tempFilePath: string | null = null; + + try { + const data = await request.file(); + if (!data) { + return reply.code(400).send({ message: 'No file uploaded' }); + } + + const ext = extname(data.filename).toLowerCase(); + if (!ALLOWED_DOCUMENT_EXTENSIONS.includes(ext)) { + return reply.code(400).send({ + message: `Invalid file type. Allowed: ${ALLOWED_DOCUMENT_EXTENSIONS.join(', ')}`, + }); + } + + const mimeType = data.mimetype || 'application/octet-stream'; + if (!ALLOWED_DOCUMENT_MIMETYPES.has(mimeType)) { + return reply.code(400).send({ + message: `Invalid mime type: ${mimeType}`, + }); + } + + const filename = `${randomUUID()}${ext}`; + await mkdir(DOCUMENT_INBOX_DIR, { recursive: true }); + + const filePath = join(DOCUMENT_INBOX_DIR, filename); + tempFilePath = filePath; + + logger.info(`Uploading document to ${filePath}`); + await pipeline(data.file, createWriteStream(filePath)); + + const fileStat = await stat(filePath); + + const fields = data.fields as Record; + const title = fields.title?.value?.trim() || null; + const description = fields.description?.value?.trim() || null; + const category = fields.category?.value?.trim() || null; + const tags = parseTags(fields.tags?.value); + + const document = await prisma.document.create({ + data: { + path: filePath, + filename, + originalFilename: data.filename, + title: title || data.filename, + description, + mimeType, + fileSize: BigInt(fileStat.size), + category, + tags: tags ? (tags as unknown as Prisma.InputJsonValue) : Prisma.JsonNull, + uploaderId: request.user?.id || null, + }, + }); + + logger.info(`Document uploaded: ${document.id}`); + + return reply.code(201).send({ + message: 'Document uploaded successfully', + document: { + ...document, + fileSize: document.fileSize?.toString() ?? null, + }, + }); + } catch (error) { + if (tempFilePath) { + try { await unlink(tempFilePath); } catch { /* ignore */ } + } + logger.error('Document upload failed:', error); + return reply.code(500).send({ + message: error instanceof Error ? error.message : 'Upload failed', + }); + } + } + ); +} diff --git a/api/src/modules/media/routes/documents.routes.ts b/api/src/modules/media/routes/documents.routes.ts new file mode 100644 index 00000000..7c5b4a27 --- /dev/null +++ b/api/src/modules/media/routes/documents.routes.ts @@ -0,0 +1,186 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { createReadStream } from 'fs'; +import { access, unlink } from 'fs/promises'; +import { resolve } from 'path'; +import { Prisma } from '@prisma/client'; +import { prisma } from '../../../config/database'; +import { requireAdminRole } from '../middleware/auth'; +import { logger } from '../../../utils/logger'; + +const DOCUMENTS_BASE = '/media/local/documents'; + +interface PublicListQuery { + tag?: string; + category?: string; + published?: string; + limit?: string; + offset?: string; +} + +interface DocumentUpdateBody { + title?: string; + description?: string; + tags?: string[]; + category?: string; + isPublished?: boolean; + position?: number; +} + +function serializeDocument(doc: T) { + return { + ...doc, + fileSize: doc.fileSize?.toString() ?? null, + }; +} + +export async function documentsRoutes(fastify: FastifyInstance) { + fastify.get<{ Querystring: PublicListQuery }>( + '/', + async (request) => { + const limit = Math.min(parseInt(request.query.limit || '100'), 500); + const offset = parseInt(request.query.offset || '0'); + const { tag, category } = request.query; + const publishedParam = request.query.published; + + const where: Prisma.DocumentWhereInput = {}; + + if (publishedParam === undefined || publishedParam === 'true') { + where.isPublished = true; + } else if (publishedParam === 'false') { + where.isPublished = false; + } + + if (category) where.category = category; + if (tag) { + where.tags = { array_contains: tag } as Prisma.JsonFilter; + } + + const items = await prisma.document.findMany({ + where, + orderBy: [ + { position: 'asc' }, + { createdAt: 'desc' }, + ], + take: limit, + skip: offset, + }); + + return { items: items.map(serializeDocument) }; + } + ); + + fastify.get<{ Params: { id: string } }>( + '/:id', + async (request, reply) => { + const { id } = request.params; + const document = await prisma.document.findUnique({ where: { id } }); + + if (!document) { + return reply.code(404).send({ message: 'Document not found' }); + } + + if (!document.isPublished) { + return reply.code(404).send({ message: 'Document not found' }); + } + + return serializeDocument(document); + } + ); + + fastify.get<{ Params: { id: string } }>( + '/:id/download', + async (request, reply) => { + const { id } = request.params; + + const document = await prisma.document.findUnique({ where: { id } }); + if (!document || !document.isPublished) { + return reply.code(404).send({ message: 'Document not found' }); + } + + const resolvedPath = resolve(document.path); + if (!resolvedPath.startsWith(resolve(DOCUMENTS_BASE) + '/')) { + logger.warn(`Document path traversal attempt blocked: ${document.path}`); + return reply.code(403).send({ message: 'Access denied' }); + } + + try { + await access(resolvedPath); + } catch { + return reply.code(404).send({ message: 'Document file not found' }); + } + + await prisma.document.update({ + where: { id }, + data: { downloadCount: { increment: 1 } }, + }); + + const downloadName = document.originalFilename || document.filename; + const safeName = downloadName.replace(/"/g, ''); + + reply.header('Content-Type', document.mimeType); + reply.header('Content-Disposition', `attachment; filename="${safeName}"`); + if (document.fileSize) { + reply.header('Content-Length', document.fileSize.toString()); + } + return reply.send(createReadStream(resolvedPath)); + } + ); + + fastify.put<{ Params: { id: string }; Body: DocumentUpdateBody }>( + '/:id', + { preHandler: requireAdminRole }, + async (request: FastifyRequest<{ Params: { id: string }; Body: DocumentUpdateBody }>, reply: FastifyReply) => { + const { id } = request.params; + const { title, description, tags, category, isPublished, position } = request.body; + + const existing = await prisma.document.findUnique({ where: { id } }); + if (!existing) { + return reply.code(404).send({ message: 'Document not found' }); + } + + const updated = await prisma.document.update({ + where: { id }, + data: { + ...(title !== undefined && { title }), + ...(description !== undefined && { description }), + ...(category !== undefined && { category }), + ...(isPublished !== undefined && { isPublished }), + ...(position !== undefined && { position }), + ...(tags !== undefined && { + tags: tags === null + ? Prisma.JsonNull + : (tags as unknown as Prisma.InputJsonValue), + }), + }, + }); + + return serializeDocument(updated); + } + ); + + fastify.delete<{ Params: { id: string } }>( + '/:id', + { preHandler: requireAdminRole }, + async (request, reply) => { + const { id } = request.params; + + const document = await prisma.document.findUnique({ where: { id } }); + if (!document) { + return reply.code(404).send({ message: 'Document not found' }); + } + + const resolvedPath = resolve(document.path); + if (resolvedPath.startsWith(resolve(DOCUMENTS_BASE) + '/')) { + try { + await unlink(resolvedPath); + } catch (err) { + logger.warn(`Failed to unlink document file ${resolvedPath}:`, err); + } + } + + await prisma.document.delete({ where: { id } }); + + return { message: 'Document deleted' }; + } + ); +}