- StorageProvider interface with LocalProvider (S3 placeholder) - File table with entity_type/entity_id references, content type, path - POST /v1/files (multipart upload), GET /v1/files (list by entity), GET /v1/files/:id (metadata), GET /v1/files/serve/* (content), DELETE /v1/files/:id - member_identifier drops base64 columns, uses file_id FKs - File validation: type whitelist, size limits, per-entity max - Fastify storage plugin injects provider into app - 6 API tests for upload, list, get, delete, validation - Test runner kills stale port before starting backend
107 lines
4.1 KiB
TypeScript
107 lines
4.1 KiB
TypeScript
import type { FastifyPluginAsync } from 'fastify'
|
|
import multipart from '@fastify/multipart'
|
|
import { FileService } from '../../services/file.service.js'
|
|
|
|
export const fileRoutes: FastifyPluginAsync = async (app) => {
|
|
await app.register(multipart, {
|
|
limits: {
|
|
fileSize: 25 * 1024 * 1024, // 25 MB max
|
|
files: 1,
|
|
},
|
|
})
|
|
|
|
// List files for an entity
|
|
app.get('/files', { preHandler: [app.authenticate] }, async (request, reply) => {
|
|
const { entityType, entityId } = request.query as { entityType?: string; entityId?: string }
|
|
if (!entityType || !entityId) {
|
|
return reply.status(400).send({
|
|
error: { message: 'entityType and entityId query params required', statusCode: 400 },
|
|
})
|
|
}
|
|
|
|
const fileRecords = await FileService.listByEntity(app.db, request.companyId, entityType, entityId)
|
|
const data = await Promise.all(
|
|
fileRecords.map(async (f) => ({ ...f, url: await app.storage.getUrl(f.path) })),
|
|
)
|
|
return reply.send({ data })
|
|
})
|
|
|
|
// Upload a file
|
|
app.post('/files', { preHandler: [app.authenticate] }, async (request, reply) => {
|
|
const data = await request.file()
|
|
if (!data) {
|
|
return reply.status(400).send({ error: { message: 'No file provided', statusCode: 400 } })
|
|
}
|
|
|
|
const entityType = (data.fields.entityType as { value?: string })?.value
|
|
const entityId = (data.fields.entityId as { value?: string })?.value
|
|
const category = (data.fields.category as { value?: string })?.value
|
|
|
|
if (!entityType || !entityId || !category) {
|
|
return reply.status(400).send({
|
|
error: { message: 'entityType, entityId, and category are required', statusCode: 400 },
|
|
})
|
|
}
|
|
|
|
const buffer = await data.toBuffer()
|
|
|
|
try {
|
|
const file = await FileService.upload(app.db, app.storage, request.companyId, {
|
|
data: buffer,
|
|
filename: data.filename,
|
|
contentType: data.mimetype,
|
|
entityType,
|
|
entityId,
|
|
category,
|
|
uploadedBy: request.user.id,
|
|
})
|
|
const url = await app.storage.getUrl(file.path)
|
|
return reply.status(201).send({ ...file, url })
|
|
} catch (err) {
|
|
if (err instanceof Error && (err.message.includes('not allowed') || err.message.includes('too large') || err.message.includes('Maximum'))) {
|
|
return reply.status(400).send({ error: { message: err.message, statusCode: 400 } })
|
|
}
|
|
throw err
|
|
}
|
|
})
|
|
|
|
// Serve file content (for local provider)
|
|
app.get('/files/serve/*', { preHandler: [app.authenticate] }, async (request, reply) => {
|
|
const filePath = (request.params as { '*': string })['*']
|
|
if (!filePath) {
|
|
return reply.status(400).send({ error: { message: 'Path required', statusCode: 400 } })
|
|
}
|
|
|
|
try {
|
|
const data = await app.storage.get(filePath)
|
|
const ext = filePath.split('.').pop()?.toLowerCase()
|
|
const contentTypeMap: Record<string, string> = {
|
|
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', pdf: 'application/pdf',
|
|
}
|
|
return reply
|
|
.header('Content-Type', contentTypeMap[ext ?? ''] ?? 'application/octet-stream')
|
|
.header('Cache-Control', 'private, max-age=3600')
|
|
.send(data)
|
|
} catch {
|
|
return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } })
|
|
}
|
|
})
|
|
|
|
// Get file metadata
|
|
app.get('/files/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
|
|
const { id } = request.params as { id: string }
|
|
const file = await FileService.getById(app.db, request.companyId, id)
|
|
if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } })
|
|
const url = await app.storage.getUrl(file.path)
|
|
return reply.send({ ...file, url })
|
|
})
|
|
|
|
// Delete a file
|
|
app.delete('/files/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
|
|
const { id } = request.params as { id: string }
|
|
const file = await FileService.delete(app.db, app.storage, request.companyId, id)
|
|
if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } })
|
|
return reply.send(file)
|
|
})
|
|
}
|