From 49e668ee2a28d379940c5026eae2abe09dc35e62 Mon Sep 17 00:00:00 2001 From: BOHA Date: Mon, 23 Mar 2026 10:19:11 +0100 Subject: [PATCH] feat: add project files REST endpoints with auth and audit logging Co-Authored-By: Claude Opus 4.6 (1M context) --- src/routes/admin/project-files.ts | 215 ++++++++++++++++++++++++++++++ src/server.ts | 2 + src/types/index.ts | 3 +- 3 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 src/routes/admin/project-files.ts diff --git a/src/routes/admin/project-files.ts b/src/routes/admin/project-files.ts new file mode 100644 index 0000000..12a2b4b --- /dev/null +++ b/src/routes/admin/project-files.ts @@ -0,0 +1,215 @@ +import fs from 'fs'; +import { FastifyInstance } from 'fastify'; +import multipart from '@fastify/multipart'; +import prisma from '../../config/database'; +import { config } from '../../config/env'; +import { requirePermission } from '../../middleware/auth'; +import { logAudit } from '../../services/audit'; +import { success, error } from '../../utils/response'; +import { NasFileManager } from '../../services/nas-file-manager'; + +export default async function projectFilesRoutes(fastify: FastifyInstance): Promise { + await fastify.register(multipart, { limits: { fileSize: config.nas.maxUploadSize } }); + + const fm = new NasFileManager(); + + async function getProjectForFiles(projectId: number) { + if (!projectId || isNaN(projectId) || projectId <= 0) return null; + return prisma.projects.findUnique({ + where: { id: projectId }, + select: { id: true, project_number: true, name: true }, + }); + } + + // GET / — list files or download + fastify.get('/', { preHandler: requirePermission('projects.view') }, async (request, reply) => { + const query = request.query as Record; + const projectId = Number(query.project_id); + const project = await getProjectForFiles(projectId); + if (!project) return error(reply, 'Projekt nebyl nalezen', 404); + + if (!fm.isConfigured()) { + return error(reply, 'Souborový systém není nakonfigurován', 500); + } + + const subPath = query.path || ''; + + if (query.action === 'download') { + if (!subPath) return error(reply, 'Cesta k souboru je povinná'); + if (!project.project_number) return error(reply, 'Projekt nemá číslo projektu'); + + const result = fm.downloadFile(project.project_number, subPath); + if (!result) return error(reply, 'Soubor nebyl nalezen', 404); + + const stream = fs.createReadStream(result.filePath); + return reply + .header('Content-Disposition', `attachment; filename="${encodeURIComponent(result.fileName)}"`) + .header('Content-Type', result.mime) + .header('X-Content-Type-Options', 'nosniff') + .send(stream); + } + + // List files + if (!project.project_number) return error(reply, 'Projekt nemá číslo projektu'); + + const result = fm.listFiles(project.project_number, subPath); + if (result === null) { + return error(reply, 'Složka nebyla nalezena', 404); + } + + return success(reply, { + ...result, + project_number: project.project_number, + folder_exists: true, + }); + }); + + // POST / — create folder (JSON body) + fastify.post('/', { preHandler: requirePermission('projects.files') }, async (request, reply) => { + const query = request.query as Record; + const projectId = Number(query.project_id); + const project = await getProjectForFiles(projectId); + if (!project) return error(reply, 'Projekt nebyl nalezen', 404); + if (!project.project_number) return error(reply, 'Projekt nemá číslo projektu'); + + if (!fm.isConfigured()) { + return error(reply, 'Souborový systém není nakonfigurován', 500); + } + + const body = request.body as Record; + const folderName = String(body.folder_name || '').trim(); + const path = String(body.path || ''); + + if (!folderName) return error(reply, 'Název složky je povinný'); + if ([...folderName].length > 100) return error(reply, 'Název složky je příliš dlouhý (max 100 znaků)'); + + // Auto-create project folder if it doesn't exist + if (!fm.projectFolderExists(project.project_number)) { + fm.createProjectFolder(project.project_number, project.name || ''); + } + + const err = fm.createFolder(project.project_number, path, folderName); + if (err !== null) return error(reply, err); + + await logAudit({ + request, + authData: request.authData, + action: 'create', + entityType: 'project_file', + entityId: project.id, + description: `Vytvořena složka '${folderName}' v projektu '${project.project_number}'`, + newValues: { folder: folderName, path }, + }); + + return success(reply, null, 200, 'Složka byla vytvořena'); + }); + + // POST /upload — upload file (multipart) + fastify.post('/upload', { + preHandler: requirePermission('projects.files'), + bodyLimit: config.nas.maxUploadSize, + }, async (request, reply) => { + const query = request.query as Record; + const projectId = Number(query.project_id); + const project = await getProjectForFiles(projectId); + if (!project) return error(reply, 'Projekt nebyl nalezen', 404); + if (!project.project_number) return error(reply, 'Projekt nemá číslo projektu'); + + if (!fm.isConfigured()) { + return error(reply, 'Souborový systém není nakonfigurován', 500); + } + + // Auto-create project folder if it doesn't exist + if (!fm.projectFolderExists(project.project_number)) { + fm.createProjectFolder(project.project_number, project.name || ''); + } + + const file = await request.file(); + if (!file) return error(reply, 'Nebyl nahrán žádný soubor'); + + const subPath = query.path || ''; + const fileBuffer = await file.toBuffer(); + const fileName = file.filename; + + const err = await fm.uploadFile(project.project_number, subPath, fileBuffer, fileName); + if (err !== null) return error(reply, err); + + await logAudit({ + request, + authData: request.authData, + action: 'create', + entityType: 'project_file', + entityId: project.id, + description: `Nahrán soubor do projektu '${project.project_number}'`, + newValues: { file: fileName, path: subPath }, + }); + + return success(reply, null, 200, 'Soubor byl nahrán'); + }); + + // PUT / — move/rename + fastify.put('/', { preHandler: requirePermission('projects.files') }, async (request, reply) => { + const query = request.query as Record; + const projectId = Number(query.project_id); + const project = await getProjectForFiles(projectId); + if (!project) return error(reply, 'Projekt nebyl nalezen', 404); + if (!project.project_number) return error(reply, 'Projekt nemá číslo projektu'); + + if (!fm.isConfigured()) { + return error(reply, 'Souborový systém není nakonfigurován', 500); + } + + const body = request.body as Record; + const fromPath = String(body.from_path || ''); + const toPath = String(body.to_path || ''); + + if (!fromPath || !toPath) return error(reply, 'Zdrojová i cílová cesta jsou povinné'); + + const err = fm.moveItem(project.project_number, fromPath, toPath); + if (err !== null) return error(reply, err); + + await logAudit({ + request, + authData: request.authData, + action: 'update', + entityType: 'project_file', + entityId: project.id, + description: `Přesun/přejmenování v projektu '${project.project_number}'`, + oldValues: { path: fromPath }, + newValues: { path: toPath }, + }); + + return success(reply, null, 200, 'Soubor byl přesunut'); + }); + + // DELETE / — delete file/folder + fastify.delete('/', { preHandler: requirePermission('projects.files') }, async (request, reply) => { + const query = request.query as Record; + const projectId = Number(query.project_id); + const project = await getProjectForFiles(projectId); + if (!project) return error(reply, 'Projekt nebyl nalezen', 404); + if (!project.project_number) return error(reply, 'Projekt nemá číslo projektu'); + + if (!fm.isConfigured()) { + return error(reply, 'Souborový systém není nakonfigurován', 500); + } + + const filePath = query.path || ''; + if (!filePath) return error(reply, 'Cesta k souboru je povinná'); + + const err = await fm.deleteItem(project.project_number, filePath); + if (err !== null) return error(reply, err); + + await logAudit({ + request, + authData: request.authData, + action: 'delete', + entityType: 'project_file', + entityId: project.id, + description: `Smazán soubor/složka v projektu '${project.project_number}'`, + oldValues: { path: filePath }, + }); + + return success(reply, null, 200, 'Soubor byl smazán'); + }); +} diff --git a/src/server.ts b/src/server.ts index 92a182b..26d18d4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -30,6 +30,7 @@ import totpRoutes from './routes/admin/totp'; import scopeTemplatesRoutes from './routes/admin/scope-templates'; import invoicesPdfRoutes from './routes/admin/invoices-pdf'; import offersPdfRoutes from './routes/admin/offers-pdf'; +import projectFilesRoutes from './routes/admin/project-files'; const app = Fastify({ logger: { @@ -96,6 +97,7 @@ async function start() { await app.register(scopeTemplatesRoutes, { prefix: '/api/admin/offers-templates' }); await app.register(invoicesPdfRoutes, { prefix: '/api/admin/invoices-pdf' }); await app.register(offersPdfRoutes, { prefix: '/api/admin/offers-pdf' }); + await app.register(projectFilesRoutes, { prefix: '/api/admin/project-files' }); // --- Health check --- app.get('/api/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() })); diff --git a/src/types/index.ts b/src/types/index.ts index 5953014..cce27f0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -126,4 +126,5 @@ export type EntityType = | 'vehicle' | 'bank_account' | 'company_settings' - | 'leave_balance'; + | 'leave_balance' + | 'project_file';