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' }; + } + ); +}