Implement file storage layer with local provider, upload/download API, tests

- 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
This commit is contained in:
Ryan Moon
2026-03-28 15:29:06 -05:00
parent de4d2e0a32
commit 760e995ae3
19 changed files with 615 additions and 6 deletions

View File

@@ -0,0 +1,106 @@
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)
})
}