From 760e995ae317caec317d59e4e94335f326fcd920 Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Sat, 28 Mar 2026 15:29:06 -0500 Subject: [PATCH] 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 --- bun.lock | 7 + packages/backend/api-tests/lib/context.ts | 4 + packages/backend/api-tests/run.ts | 10 ++ packages/backend/api-tests/suites/files.ts | 146 ++++++++++++++++++ packages/backend/package.json | 1 + .../src/db/migrations/0012_file_storage.sql | 25 +++ .../src/db/migrations/meta/_journal.json | 7 + packages/backend/src/db/schema/accounts.ts | 4 +- packages/backend/src/db/schema/files.ts | 21 +++ packages/backend/src/main.ts | 4 + packages/backend/src/plugins/storage.ts | 14 ++ packages/backend/src/routes/v1/files.ts | 106 +++++++++++++ .../backend/src/services/account.service.ts | 4 +- packages/backend/src/services/file.service.ts | 139 +++++++++++++++++ packages/backend/src/storage/index.ts | 23 +++ packages/backend/src/storage/local.ts | 48 ++++++ packages/backend/src/storage/provider.ts | 7 + packages/backend/src/storage/s3.ts | 47 ++++++ packages/shared/src/schemas/account.schema.ts | 4 +- 19 files changed, 615 insertions(+), 6 deletions(-) create mode 100644 packages/backend/api-tests/suites/files.ts create mode 100644 packages/backend/src/db/migrations/0012_file_storage.sql create mode 100644 packages/backend/src/db/schema/files.ts create mode 100644 packages/backend/src/plugins/storage.ts create mode 100644 packages/backend/src/routes/v1/files.ts create mode 100644 packages/backend/src/services/file.service.ts create mode 100644 packages/backend/src/storage/index.ts create mode 100644 packages/backend/src/storage/local.ts create mode 100644 packages/backend/src/storage/provider.ts create mode 100644 packages/backend/src/storage/s3.ts diff --git a/bun.lock b/bun.lock index 290f5e6..f74775c 100644 --- a/bun.lock +++ b/bun.lock @@ -59,6 +59,7 @@ "dependencies": { "@fastify/cors": "^10", "@fastify/jwt": "^9", + "@fastify/multipart": "^9.4.0", "@fastify/rate-limit": "^10.3.0", "@forte/shared": "workspace:*", "bcrypt": "^6", @@ -209,8 +210,12 @@ "@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="], + "@fastify/busboy": ["@fastify/busboy@3.2.0", "", {}, "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA=="], + "@fastify/cors": ["@fastify/cors@10.1.0", "", { "dependencies": { "fastify-plugin": "^5.0.0", "mnemonist": "0.40.0" } }, "sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ=="], + "@fastify/deepmerge": ["@fastify/deepmerge@3.2.1", "", {}, "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA=="], + "@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="], "@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="], @@ -221,6 +226,8 @@ "@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="], + "@fastify/multipart": ["@fastify/multipart@9.4.0", "", { "dependencies": { "@fastify/busboy": "^3.0.0", "@fastify/deepmerge": "^3.0.0", "@fastify/error": "^4.0.0", "fastify-plugin": "^5.0.0", "secure-json-parse": "^4.0.0" } }, "sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ=="], + "@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="], "@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="], diff --git a/packages/backend/api-tests/lib/context.ts b/packages/backend/api-tests/lib/context.ts index 93e8392..691075b 100644 --- a/packages/backend/api-tests/lib/context.ts +++ b/packages/backend/api-tests/lib/context.ts @@ -3,6 +3,8 @@ import { type TestResult, printSuiteHeader, printTestResult } from './reporter.j export interface TestContext { api: ApiClient + token: string + baseUrl: string test: (name: string, optsOrFn: { tags?: string[] } | (() => Promise), maybeFn?: () => Promise) => void assert: { status: (res: ApiResponse, expected: number) => void @@ -117,6 +119,8 @@ export async function runSuite( const ctx: TestContext = { api, + token, + baseUrl, assert: makeAssert(), test(name, optsOrFn, maybeFn) { const opts = typeof optsOrFn === 'function' ? {} : optsOrFn diff --git a/packages/backend/api-tests/run.ts b/packages/backend/api-tests/run.ts index 81f7e8f..5bf3862 100644 --- a/packages/backend/api-tests/run.ts +++ b/packages/backend/api-tests/run.ts @@ -77,7 +77,16 @@ async function setupDatabase() { } // --- Start backend --- +async function killPort(port: number) { + try { + const { execSync } = await import('child_process') + execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null || true`, { stdio: 'pipe' }) + await new Promise((r) => setTimeout(r, 1000)) + } catch {} +} + async function startBackend(): Promise { + await killPort(TEST_PORT) const proc = spawn({ cmd: ['bun', 'run', 'src/main.ts'], cwd: new URL('..', import.meta.url).pathname, @@ -90,6 +99,7 @@ async function startBackend(): Promise { HOST: '0.0.0.0', NODE_ENV: 'development', LOG_LEVEL: 'error', + STORAGE_LOCAL_PATH: '/tmp/forte-test-files', }, stdout: 'pipe', stderr: 'pipe', diff --git a/packages/backend/api-tests/suites/files.ts b/packages/backend/api-tests/suites/files.ts new file mode 100644 index 0000000..c4032cb --- /dev/null +++ b/packages/backend/api-tests/suites/files.ts @@ -0,0 +1,146 @@ +import { suite } from '../lib/context.js' + +// Helper: create a tiny 1x1 JPEG for testing uploads +const TINY_JPEG = Buffer.from( + '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB' + + 'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEB' + + 'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIA' + + 'AhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEA' + + 'AAAAAAAAAAAAAAAAAAAB/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AJgA//9k=', + 'base64', +) + +suite('Files', { tags: ['files', 'storage'] }, (t) => { + t.test('uploads an image file', { tags: ['upload'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'File Test' }) + const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { + firstName: 'File', + lastName: 'Test', + }) + + // Upload via multipart + const formData = new FormData() + formData.append('file', new Blob([TINY_JPEG], { type: 'image/jpeg' }), 'test.jpg') + formData.append('entityType', 'member_identifier') + formData.append('entityId', member.data.id) + formData.append('category', 'front') + + const res = await fetch(`${t.baseUrl}/v1/files`, { + method: 'POST', + headers: { Authorization: `Bearer ${t.token}` }, + body: formData, + }) + const data = await res.json() + + t.assert.equal(res.status, 201) + t.assert.ok(data.id) + t.assert.equal(data.contentType, 'image/jpeg') + t.assert.equal(data.entityType, 'member_identifier') + t.assert.equal(data.category, 'front') + t.assert.ok(data.url) + }) + + t.test('lists files for an entity', { tags: ['read'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'File List Test' }) + const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { + firstName: 'List', + lastName: 'Files', + }) + + // Upload a file first + const formData = new FormData() + formData.append('file', new Blob([TINY_JPEG], { type: 'image/jpeg' }), 'list-test.jpg') + formData.append('entityType', 'member_identifier') + formData.append('entityId', member.data.id) + formData.append('category', 'back') + + await fetch(`${t.baseUrl}/v1/files`, { + method: 'POST', + headers: { Authorization: `Bearer ${t.token}` }, + body: formData, + }) + + const res = await t.api.get('/v1/files', { + entityType: 'member_identifier', + entityId: member.data.id, + }) + t.assert.status(res, 200) + t.assert.greaterThan(res.data.data.length, 0) + t.assert.ok(res.data.data[0].url) + }) + + t.test('gets file metadata by id', { tags: ['read'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'File Get Test' }) + const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { + firstName: 'Get', + lastName: 'File', + }) + + const formData = new FormData() + formData.append('file', new Blob([TINY_JPEG], { type: 'image/jpeg' }), 'get-test.jpg') + formData.append('entityType', 'member_identifier') + formData.append('entityId', member.data.id) + formData.append('category', 'front') + + const uploadRes = await fetch(`${t.baseUrl}/v1/files`, { + method: 'POST', + headers: { Authorization: `Bearer ${t.token}` }, + body: formData, + }) + const uploaded = await uploadRes.json() + + const res = await t.api.get(`/v1/files/${uploaded.id}`) + t.assert.status(res, 200) + t.assert.equal(res.data.id, uploaded.id) + t.assert.equal(res.data.filename, 'get-test.jpg') + }) + + t.test('deletes a file', { tags: ['delete'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'File Delete Test' }) + const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { + firstName: 'Delete', + lastName: 'File', + }) + + const formData = new FormData() + formData.append('file', new Blob([TINY_JPEG], { type: 'image/jpeg' }), 'delete-test.jpg') + formData.append('entityType', 'member_identifier') + formData.append('entityId', member.data.id) + formData.append('category', 'front') + + const uploadRes = await fetch(`${t.baseUrl}/v1/files`, { + method: 'POST', + headers: { Authorization: `Bearer ${t.token}` }, + body: formData, + }) + const uploaded = await uploadRes.json() + + const res = await t.api.del(`/v1/files/${uploaded.id}`) + t.assert.status(res, 200) + + const check = await t.api.get(`/v1/files/${uploaded.id}`) + t.assert.status(check, 404) + }) + + t.test('rejects unsupported file types', { tags: ['validation'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'File Reject Test' }) + + const formData = new FormData() + formData.append('file', new Blob(['not an image'], { type: 'text/plain' }), 'test.txt') + formData.append('entityType', 'member_identifier') + formData.append('entityId', acct.data.id) + formData.append('category', 'front') + + const res = await fetch(`${t.baseUrl}/v1/files`, { + method: 'POST', + headers: { Authorization: `Bearer ${t.token}` }, + body: formData, + }) + t.assert.equal(res.status, 400) + }) + + t.test('returns 404 for missing file', { tags: ['read'] }, async () => { + const res = await t.api.get('/v1/files/a0000000-0000-0000-0000-999999999999') + t.assert.status(res, 404) + }) +}) diff --git a/packages/backend/package.json b/packages/backend/package.json index 5a5f61f..895cd7f 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -17,6 +17,7 @@ "dependencies": { "@fastify/cors": "^10", "@fastify/jwt": "^9", + "@fastify/multipart": "^9.4.0", "@fastify/rate-limit": "^10.3.0", "@forte/shared": "workspace:*", "bcrypt": "^6", diff --git a/packages/backend/src/db/migrations/0012_file_storage.sql b/packages/backend/src/db/migrations/0012_file_storage.sql new file mode 100644 index 0000000..94eb3b1 --- /dev/null +++ b/packages/backend/src/db/migrations/0012_file_storage.sql @@ -0,0 +1,25 @@ +-- File storage table +CREATE TABLE IF NOT EXISTS "file" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "company_id" uuid NOT NULL REFERENCES "company"("id"), + "path" varchar(1000) NOT NULL, + "filename" varchar(255) NOT NULL, + "content_type" varchar(100) NOT NULL, + "size_bytes" integer NOT NULL, + "entity_type" varchar(100) NOT NULL, + "entity_id" uuid NOT NULL, + "category" varchar(100) NOT NULL, + "uploaded_by" uuid, + "created_at" timestamp with time zone NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX "file_company_path" ON "file" ("company_id", "path"); +CREATE INDEX "file_entity" ON "file" ("company_id", "entity_type", "entity_id"); + +-- Update member_identifier: replace base64 columns with file references +ALTER TABLE "member_identifier" DROP COLUMN IF EXISTS "image_front"; +ALTER TABLE "member_identifier" DROP COLUMN IF EXISTS "image_back"; +ALTER TABLE "member_identifier" DROP COLUMN IF EXISTS "image_front_url"; +ALTER TABLE "member_identifier" DROP COLUMN IF EXISTS "image_back_url"; +ALTER TABLE "member_identifier" ADD COLUMN "image_front_file_id" uuid REFERENCES "file"("id"); +ALTER TABLE "member_identifier" ADD COLUMN "image_back_file_id" uuid REFERENCES "file"("id"); diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index 9186a9b..d08a3b6 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1774710000000, "tag": "0011_member_address", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1774720000000, + "tag": "0012_file_storage", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/accounts.ts b/packages/backend/src/db/schema/accounts.ts index 3183d39..ee143a8 100644 --- a/packages/backend/src/db/schema/accounts.ts +++ b/packages/backend/src/db/schema/accounts.ts @@ -82,8 +82,8 @@ export const memberIdentifiers = pgTable('member_identifier', { issuingAuthority: varchar('issuing_authority', { length: 255 }), issuedDate: date('issued_date'), expiresAt: date('expires_at'), - imageFront: text('image_front'), - imageBack: text('image_back'), + imageFrontFileId: uuid('image_front_file_id'), + imageBackFileId: uuid('image_back_file_id'), notes: text('notes'), isPrimary: boolean('is_primary').notNull().default(false), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), diff --git a/packages/backend/src/db/schema/files.ts b/packages/backend/src/db/schema/files.ts new file mode 100644 index 0000000..95a26ff --- /dev/null +++ b/packages/backend/src/db/schema/files.ts @@ -0,0 +1,21 @@ +import { pgTable, uuid, varchar, integer, timestamp } from 'drizzle-orm/pg-core' +import { companies } from './stores.js' + +export const files = pgTable('file', { + id: uuid('id').primaryKey().defaultRandom(), + companyId: uuid('company_id') + .notNull() + .references(() => companies.id), + path: varchar('path', { length: 1000 }).notNull(), + filename: varchar('filename', { length: 255 }).notNull(), + contentType: varchar('content_type', { length: 100 }).notNull(), + sizeBytes: integer('size_bytes').notNull(), + entityType: varchar('entity_type', { length: 100 }).notNull(), + entityId: uuid('entity_id').notNull(), + category: varchar('category', { length: 100 }).notNull(), + uploadedBy: uuid('uploaded_by'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export type FileRecord = typeof files.$inferSelect +export type FileRecordInsert = typeof files.$inferInsert diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index d94e76b..90bc09e 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -6,12 +6,14 @@ import { corsPlugin } from './plugins/cors.js' import { errorHandlerPlugin } from './plugins/error-handler.js' import { authPlugin } from './plugins/auth.js' import { devAuthPlugin } from './plugins/dev-auth.js' +import { storagePlugin } from './plugins/storage.js' import { healthRoutes } from './routes/v1/health.js' import { authRoutes } from './routes/v1/auth.js' import { accountRoutes } from './routes/v1/accounts.js' import { inventoryRoutes } from './routes/v1/inventory.js' import { productRoutes } from './routes/v1/products.js' import { lookupRoutes } from './routes/v1/lookups.js' +import { fileRoutes } from './routes/v1/files.js' export async function buildApp() { const app = Fastify({ @@ -28,6 +30,7 @@ export async function buildApp() { await app.register(databasePlugin) await app.register(redisPlugin) await app.register(rateLimit, { global: false }) + await app.register(storagePlugin) // Auth — JWT in production/test, dev bypass only in development without JWT_SECRET if (process.env.JWT_SECRET) { @@ -46,6 +49,7 @@ export async function buildApp() { await app.register(inventoryRoutes, { prefix: '/v1' }) await app.register(productRoutes, { prefix: '/v1' }) await app.register(lookupRoutes, { prefix: '/v1' }) + await app.register(fileRoutes, { prefix: '/v1' }) return app } diff --git a/packages/backend/src/plugins/storage.ts b/packages/backend/src/plugins/storage.ts new file mode 100644 index 0000000..15955ef --- /dev/null +++ b/packages/backend/src/plugins/storage.ts @@ -0,0 +1,14 @@ +import fp from 'fastify-plugin' +import { createStorageProvider, type StorageProvider } from '../storage/index.js' + +declare module 'fastify' { + interface FastifyInstance { + storage: StorageProvider + } +} + +export const storagePlugin = fp(async (app) => { + const storage = createStorageProvider() + app.decorate('storage', storage) + app.log.info(`Storage provider: ${process.env.STORAGE_PROVIDER ?? 'local'}`) +}) diff --git a/packages/backend/src/routes/v1/files.ts b/packages/backend/src/routes/v1/files.ts new file mode 100644 index 0000000..3a2499c --- /dev/null +++ b/packages/backend/src/routes/v1/files.ts @@ -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 = { + 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) + }) +} diff --git a/packages/backend/src/services/account.service.ts b/packages/backend/src/services/account.service.ts index 0311957..c0b2c47 100644 --- a/packages/backend/src/services/account.service.ts +++ b/packages/backend/src/services/account.service.ts @@ -607,8 +607,8 @@ export const MemberIdentifierService = { issuingAuthority: input.issuingAuthority, issuedDate: input.issuedDate, expiresAt: input.expiresAt, - imageFrontUrl: input.imageFrontUrl, - imageBackUrl: input.imageBackUrl, + imageFrontFileId: input.imageFrontFileId, + imageBackFileId: input.imageBackFileId, notes: input.notes, isPrimary: input.isPrimary, }) diff --git a/packages/backend/src/services/file.service.ts b/packages/backend/src/services/file.service.ts new file mode 100644 index 0000000..abc55e5 --- /dev/null +++ b/packages/backend/src/services/file.service.ts @@ -0,0 +1,139 @@ +import { eq, and, count } from 'drizzle-orm' +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' +import { files } from '../db/schema/files.js' +import type { StorageProvider } from '../storage/index.js' +import { randomUUID } from 'crypto' + +const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'] +const ALLOWED_PDF_TYPES = ['application/pdf'] +const ALLOWED_TYPES = [...ALLOWED_IMAGE_TYPES, ...ALLOWED_PDF_TYPES] +const MAX_IMAGE_SIZE = 10 * 1024 * 1024 // 10 MB +const MAX_PDF_SIZE = 25 * 1024 * 1024 // 25 MB +const MAX_FILES_PER_ENTITY = 20 + +function getExtension(contentType: string): string { + const map: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'application/pdf': 'pdf', + } + return map[contentType] ?? 'bin' +} + +export const FileService = { + async upload( + db: PostgresJsDatabase, + storage: StorageProvider, + companyId: string, + input: { + data: Buffer + filename: string + contentType: string + entityType: string + entityId: string + category: string + uploadedBy?: string + }, + ) { + // Validate content type + if (!ALLOWED_TYPES.includes(input.contentType)) { + throw new Error(`File type not allowed: ${input.contentType}`) + } + + // Validate size + const maxSize = ALLOWED_IMAGE_TYPES.includes(input.contentType) ? MAX_IMAGE_SIZE : MAX_PDF_SIZE + if (input.data.length > maxSize) { + throw new Error(`File too large: ${input.data.length} bytes (max ${maxSize})`) + } + + // Check per-entity limit + const [existing] = await db + .select({ total: count() }) + .from(files) + .where( + and( + eq(files.companyId, companyId), + eq(files.entityType, input.entityType), + eq(files.entityId, input.entityId), + ), + ) + if (existing.total >= MAX_FILES_PER_ENTITY) { + throw new Error(`Maximum ${MAX_FILES_PER_ENTITY} files per entity`) + } + + // Generate path + const fileId = randomUUID() + const ext = getExtension(input.contentType) + const path = `${companyId}/${input.entityType}/${input.entityId}/${input.category}-${fileId}.${ext}` + + // Write to storage + await storage.put(path, input.data, input.contentType) + + // Insert record + const [file] = await db + .insert(files) + .values({ + id: fileId, + companyId, + path, + filename: input.filename, + contentType: input.contentType, + sizeBytes: input.data.length, + entityType: input.entityType, + entityId: input.entityId, + category: input.category, + uploadedBy: input.uploadedBy, + }) + .returning() + + return file + }, + + async getById(db: PostgresJsDatabase, companyId: string, id: string) { + const [file] = await db + .select() + .from(files) + .where(and(eq(files.id, id), eq(files.companyId, companyId))) + .limit(1) + return file ?? null + }, + + async listByEntity( + db: PostgresJsDatabase, + companyId: string, + entityType: string, + entityId: string, + ) { + return db + .select() + .from(files) + .where( + and( + eq(files.companyId, companyId), + eq(files.entityType, entityType), + eq(files.entityId, entityId), + ), + ) + .orderBy(files.createdAt) + }, + + async delete( + db: PostgresJsDatabase, + storage: StorageProvider, + companyId: string, + id: string, + ) { + const file = await this.getById(db, companyId, id) + if (!file) return null + + await storage.delete(file.path) + + const [deleted] = await db + .delete(files) + .where(and(eq(files.id, id), eq(files.companyId, companyId))) + .returning() + + return deleted ?? null + }, +} diff --git a/packages/backend/src/storage/index.ts b/packages/backend/src/storage/index.ts new file mode 100644 index 0000000..d395b40 --- /dev/null +++ b/packages/backend/src/storage/index.ts @@ -0,0 +1,23 @@ +import { LocalStorageProvider } from './local.js' +import type { StorageProvider } from './provider.js' + +export type { StorageProvider } + +export function createStorageProvider(): StorageProvider { + const provider = process.env.STORAGE_PROVIDER ?? 'local' + + if (provider === 'local') { + const root = process.env.STORAGE_LOCAL_PATH ?? './data/files' + const baseUrl = `http://localhost:${process.env.PORT ?? '8000'}` + return new LocalStorageProvider(root, baseUrl) + } + + if (provider === 's3') { + // Lazy import to avoid requiring @aws-sdk when using local + throw new Error( + 'S3 provider requires @aws-sdk/client-s3. Install it and update this factory.', + ) + } + + throw new Error(`Unknown storage provider: ${provider}`) +} diff --git a/packages/backend/src/storage/local.ts b/packages/backend/src/storage/local.ts new file mode 100644 index 0000000..8a9398d --- /dev/null +++ b/packages/backend/src/storage/local.ts @@ -0,0 +1,48 @@ +import { mkdir, readFile, writeFile, unlink, access } from 'fs/promises' +import { dirname, join } from 'path' +import type { StorageProvider } from './provider.js' + +export class LocalStorageProvider implements StorageProvider { + private root: string + private baseUrl: string + + constructor(root: string, baseUrl: string) { + this.root = root + this.baseUrl = baseUrl + } + + private fullPath(path: string): string { + return join(this.root, path) + } + + async put(path: string, data: Buffer, _contentType: string): Promise { + const fullPath = this.fullPath(path) + await mkdir(dirname(fullPath), { recursive: true }) + await writeFile(fullPath, data) + } + + async get(path: string): Promise { + return readFile(this.fullPath(path)) + } + + async delete(path: string): Promise { + try { + await unlink(this.fullPath(path)) + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err + } + } + + async exists(path: string): Promise { + try { + await access(this.fullPath(path)) + return true + } catch { + return false + } + } + + async getUrl(path: string, _expiresIn?: number): Promise { + return `${this.baseUrl}/v1/files/serve/${encodeURIComponent(path)}` + } +} diff --git a/packages/backend/src/storage/provider.ts b/packages/backend/src/storage/provider.ts new file mode 100644 index 0000000..1f77cad --- /dev/null +++ b/packages/backend/src/storage/provider.ts @@ -0,0 +1,7 @@ +export interface StorageProvider { + put(path: string, data: Buffer, contentType: string): Promise + get(path: string): Promise + delete(path: string): Promise + exists(path: string): Promise + getUrl(path: string, expiresIn?: number): Promise +} diff --git a/packages/backend/src/storage/s3.ts b/packages/backend/src/storage/s3.ts new file mode 100644 index 0000000..e9db0cb --- /dev/null +++ b/packages/backend/src/storage/s3.ts @@ -0,0 +1,47 @@ +import type { StorageProvider } from './provider.js' + +// S3 provider — requires @aws-sdk/client-s3 (install when needed) +// This is a placeholder that documents the interface. Install the SDK +// and uncomment when deploying with S3. + +export class S3StorageProvider implements StorageProvider { + private bucket: string + private region: string + private endpoint?: string + + constructor(config: { + bucket: string + region: string + endpoint?: string + accessKey: string + secretKey: string + }) { + this.bucket = config.bucket + this.region = config.region + this.endpoint = config.endpoint + // TODO: initialize S3Client from @aws-sdk/client-s3 + throw new Error( + 'S3 provider not yet implemented. Install @aws-sdk/client-s3 and implement.', + ) + } + + async put(_path: string, _data: Buffer, _contentType: string): Promise { + throw new Error('Not implemented') + } + + async get(_path: string): Promise { + throw new Error('Not implemented') + } + + async delete(_path: string): Promise { + throw new Error('Not implemented') + } + + async exists(_path: string): Promise { + throw new Error('Not implemented') + } + + async getUrl(_path: string, _expiresIn?: number): Promise { + throw new Error('Not implemented') + } +} diff --git a/packages/shared/src/schemas/account.schema.ts b/packages/shared/src/schemas/account.schema.ts index 8eee5ea..a82a188 100644 --- a/packages/shared/src/schemas/account.schema.ts +++ b/packages/shared/src/schemas/account.schema.ts @@ -66,8 +66,8 @@ export const MemberIdentifierCreateSchema = z.object({ issuingAuthority: opt(z.string().max(255)), issuedDate: opt(z.string().date()), expiresAt: opt(z.string().date()), - imageFront: opt(z.string()), - imageBack: opt(z.string()), + imageFrontFileId: opt(z.string().uuid()), + imageBackFileId: opt(z.string().uuid()), notes: opt(z.string()), isPrimary: z.boolean().default(false), })