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
This commit is contained in:
2026-04-11 10:20:54 -06:00
parent ed011a762b
commit ae5a90d8d4
5 changed files with 755 additions and 6 deletions

View File

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

View File

@@ -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<string, { value: string }>;
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',
});
}
}
);
}

View File

@@ -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<T extends { fileSize: bigint | null }>(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' };
}
);
}