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:
106
packages/backend/src/routes/v1/files.ts
Normal file
106
packages/backend/src/routes/v1/files.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user