/** * Global Blocks Routes – CRUD for global block folders and blocks */ import type { FastifyInstance } from 'fastify'; import type { DatabaseInterface } from '../database/DatabaseInterface.js'; import { requireAuth, requireLibraryAdmin } from '../auth/authMiddleware.js'; import type { AuthService } from '../auth/AuthService.js'; import { validateName, validateIdParam } from '../utils/validation.js'; import JSZip from 'jszip'; export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) { // ─── Global Block Folders ────────────────────────────── // List all folders, optionally filtered by parent fastify.get('/api/global-folders', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; const parentId = (request.query as { parentId?: string | null }).parentId; if (parentId === undefined || parentId === '' || parentId === 'null') { return db.listGlobalFolders(parentId === 'null' ? null : undefined); } const idErr = validateIdParam(parentId, 'parentId'); if (idErr) return reply.code(400).send({ error: idErr }); return db.listGlobalFolders(parentId); }); // Get a single folder fastify.get('/api/global-folders/:id', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; const { id } = request.params as { id: string }; const idErr = validateIdParam(id, 'id'); if (idErr) return reply.code(400).send({ error: idErr }); const folder = db.getGlobalFolder(id); if (!folder) return reply.code(404).send({ error: 'Folder not found' }); return folder; }); // Create a new folder fastify.post('/api/global-folders', async (request, reply) => { if (!requireLibraryAdmin(request, reply, authService)) return; const body = request.body as { name?: string; parent_id?: string | null }; const nameErr = validateName(body.name, 'name'); if (nameErr) return reply.code(400).send({ error: nameErr }); if (body.parent_id !== undefined && body.parent_id !== null) { const pidErr = validateIdParam(body.parent_id, 'parent_id'); if (pidErr) return reply.code(400).send({ error: pidErr }); } return reply.code(201).send(db.createGlobalFolder({ name: body.name, parent_id: body.parent_id ?? null })); }); // Rename / move a folder fastify.patch('/api/global-folders/:id', async (request, reply) => { if (!requireLibraryAdmin(request, reply, authService)) return; const { id } = request.params as { id: string }; const idErr = validateIdParam(id, 'id'); if (idErr) return reply.code(400).send({ error: idErr }); const body = request.body as { name?: string; parent_id?: string | null }; if (body.name !== undefined) { const nameErr = validateName(body.name, 'name'); if (nameErr) return reply.code(400).send({ error: nameErr }); } if (body.parent_id !== undefined && body.parent_id !== null) { const pidErr = validateIdParam(body.parent_id, 'parent_id'); if (pidErr) return reply.code(400).send({ error: pidErr }); // Prevent setting parent to self if (body.parent_id === id) return reply.code(400).send({ error: 'Cannot set parent to self' }); } const updated = db.updateGlobalFolder(id, { name: body.name, parent_id: body.parent_id }); if (!updated) return reply.code(404).send({ error: 'Folder not found' }); return updated; }); // Delete a folder (cascades to children, blocks get folder_id = NULL) fastify.delete('/api/global-folders/:id', async (request, reply) => { if (!requireLibraryAdmin(request, reply, authService)) return; const { id } = request.params as { id: string }; const idErr = validateIdParam(id, 'id'); if (idErr) return reply.code(400).send({ error: idErr }); const ok = db.deleteGlobalFolder(id); if (!ok) return reply.code(404).send({ error: 'Folder not found' }); return reply.code(204).send(); }); // ─── Global Blocks ──────────────────────────────────── // List all global blocks, optionally filtered by folder fastify.get('/api/global-blocks', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; const folderId = (request.query as { folderId?: string | null }).folderId; if (folderId === undefined || folderId === '' || folderId === 'null') { return db.listGlobalBlocks(folderId === 'null' ? null : undefined); } const idErr = validateIdParam(folderId, 'folderId'); if (idErr) return reply.code(400).send({ error: idErr }); return db.listGlobalBlocks(folderId); }); // Get a single global block fastify.get('/api/global-blocks/:id', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; const { id } = request.params as { id: string }; const idErr = validateIdParam(id, 'id'); if (idErr) return reply.code(400).send({ error: idErr }); const block = db.getGlobalBlock(id); if (!block) return reply.code(404).send({ error: 'Global block not found' }); return block; }); // Create a new global block fastify.post('/api/global-blocks', async (request, reply) => { if (!requireLibraryAdmin(request, reply, authService)) return; const body = request.body as { name?: string; folder_id?: string | null; block_data?: string; svg_data?: string }; const nameErr = validateName(body.name, 'name'); if (nameErr) return reply.code(400).send({ error: nameErr }); if (body.folder_id !== undefined && body.folder_id !== null) { const fidErr = validateIdParam(body.folder_id, 'folder_id'); if (fidErr) return reply.code(400).send({ error: fidErr }); } if (body.block_data !== undefined && typeof body.block_data !== 'string') { return reply.code(400).send({ error: 'block_data must be a JSON string' }); } if (body.svg_data !== undefined && typeof body.svg_data !== 'string') { return reply.code(400).send({ error: 'svg_data must be a string' }); } return reply.code(201).send(db.createGlobalBlock({ name: body.name, folder_id: body.folder_id ?? null, block_data: body.block_data ?? '{}', svg_data: body.svg_data ?? null, })); }); // Update a global block (rename, move, update data) fastify.patch('/api/global-blocks/:id', async (request, reply) => { if (!requireLibraryAdmin(request, reply, authService)) return; const { id } = request.params as { id: string }; const idErr = validateIdParam(id, 'id'); if (idErr) return reply.code(400).send({ error: idErr }); const body = request.body as { name?: string; folder_id?: string | null; block_data?: string; svg_data?: string; is_favorite?: boolean }; if (body.name !== undefined) { const nameErr = validateName(body.name, 'name'); if (nameErr) return reply.code(400).send({ error: nameErr }); } if (body.is_favorite !== undefined && typeof body.is_favorite !== 'boolean') { return reply.code(400).send({ error: 'is_favorite must be a boolean' }); } if (body.folder_id !== undefined && body.folder_id !== null) { const fidErr = validateIdParam(body.folder_id, 'folder_id'); if (fidErr) return reply.code(400).send({ error: fidErr }); } if (body.block_data !== undefined && typeof body.block_data !== 'string') { return reply.code(400).send({ error: 'block_data must be a JSON string' }); } if (body.svg_data !== undefined && typeof body.svg_data !== 'string') { return reply.code(400).send({ error: 'svg_data must be a string' }); } const updated = db.updateGlobalBlock(id, body); if (!updated) return reply.code(404).send({ error: 'Global block not found' }); return updated; }); // Delete a global block fastify.delete('/api/global-blocks/:id', async (request, reply) => { if (!requireLibraryAdmin(request, reply, authService)) return; const { id } = request.params as { id: string }; const idErr = validateIdParam(id, 'id'); if (idErr) return reply.code(400).send({ error: idErr }); const ok = db.deleteGlobalBlock(id); if (!ok) return reply.code(404).send({ error: 'Global block not found' }); return reply.code(204).send(); }); // ─── Task F1: Library-Paket Export/Import (.wcadlib) ────── // Export: komplette Library als JSON-Paket (Ordner + Blöcke) fastify.get('/api/global-blocks/export', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; const folders = db.listGlobalFolders(); const blocks = db.listGlobalBlocks(); return { format: 'wcadlib', version: 1, exported_at: new Date().toISOString(), folders: folders.map((f) => ({ id: f.id, name: f.name, parent_id: f.parent_id })), blocks: blocks.map((b) => ({ name: b.name, folder_id: b.folder_id, block_data: b.block_data, svg_data: b.svg_data, })), }; }); // Import: .wcadlib-Paket einspielen (IDs neu, Ordner-Hierarchie über Mapping erhalten) fastify.post('/api/global-blocks/import', async (request, reply) => { if (!requireLibraryAdmin(request, reply, authService)) return; const pkg = request.body as { format?: string; folders?: Array<{ id: string; name: string; parent_id: string | null }>; blocks?: Array<{ name: string; folder_id: string | null; block_data: string; svg_data: string | null }>; }; if (!pkg || pkg.format !== 'wcadlib' || !Array.isArray(pkg.blocks)) { return reply.code(400).send({ error: 'Invalid wcadlib package (format != wcadlib or blocks missing)' }); } // Ordner mit ID-Mapping importieren: const folderIdMap = new Map(); const sortedFolders = [...(pkg.folders ?? [])].sort((a, b) => { if (a.parent_id === b.parent_id) return 0; if (a.parent_id === null) return -1; if (b.parent_id === null) return 1; return 0; }); for (const f of sortedFolders) { const newParent = f.parent_id ? folderIdMap.get(f.parent_id) ?? null : null; const created = db.createGlobalFolder({ name: f.name, parent_id: newParent }); folderIdMap.set(f.id, created.id); } // Blöcke importieren: let imported = 0; for (const b of pkg.blocks) { const newFolderId = b.folder_id ? folderIdMap.get(b.folder_id) ?? null : null; db.createGlobalBlock({ name: b.name, folder_id: newFolderId, block_data: b.block_data, svg_data: b.svg_data, }); imported++; } return reply.code(201).send({ imported_blocks: imported, imported_folders: folderIdMap.size }); }); // ─── Task F7: .wcadlib als echtes ZIP-Paket ────────────── // ZIP-Export: manifest.json + blocks.json + thumbs/ (SVG je Block) fastify.get('/api/global-blocks/export-zip', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; const folders = db.listGlobalFolders(); const blocks = db.listGlobalBlocks(); const zip = new JSZip(); zip.file('manifest.json', JSON.stringify({ format: 'wcadlib-zip', version: 1, exported_at: new Date().toISOString(), block_count: blocks.length, folder_count: folders.length, })); const blockEntries = [] as Array<{ name: string; folder_id: string | null; block_data: string; thumbnail: string | null; }>; for (const b of blocks) { let thumbnail: string | null = null; if (b.svg_data) { thumbnail = `thumbs/${b.id}.svg`; zip.file(thumbnail, b.svg_data); } blockEntries.push({ name: b.name, folder_id: b.folder_id, block_data: b.block_data, thumbnail, }); } zip.file('blocks.json', JSON.stringify({ folders: folders.map((f) => ({ id: f.id, name: f.name, parent_id: f.parent_id })), blocks: blockEntries, })); const buffer = await zip.generateAsync({ type: 'nodebuffer' }); reply.header('content-type', 'application/zip'); reply.header('content-disposition', 'attachment; filename="library.wcadlib.zip"'); return reply.send(buffer); }); // ZIP-Import: .wcadlib-ZIP einspielen (Ordner-Mapping, Blöcke, Thumbnails) fastify.post('/api/global-blocks/import-zip', async (request, reply) => { if (!requireLibraryAdmin(request, reply, authService)) return; const body = request.body; if (!Buffer.isBuffer(body) || body.length === 0) { return reply.code(400).send({ error: 'ZIP body fehlt oder leer' }); } let zip: JSZip; try { zip = await JSZip.loadAsync(body); } catch { return reply.code(400).send({ error: 'Kein valides ZIP-Format' }); } const blocksFile = zip.file('blocks.json'); if (!blocksFile) { return reply.code(400).send({ error: 'blocks.json fehlt im ZIP' }); } let pkg: { folders?: Array<{ id: string; name: string; parent_id: string | null }>; blocks?: Array<{ name: string; folder_id: string | null; block_data: string; thumbnail?: string | null }> }; try { pkg = JSON.parse(await blocksFile.async('string')); } catch { return reply.code(400).send({ error: 'blocks.json ist kein valides JSON' }); } if (!Array.isArray(pkg.blocks)) { return reply.code(400).send({ error: 'blocks.json enthaelt kein blocks-Array' }); } // Optional: manifest pruefen (falls vorhanden) const manifestFile = zip.file('manifest.json'); if (manifestFile) { try { const manifest = JSON.parse(await manifestFile.async('string')); if (manifest.format && manifest.format !== 'wcadlib-zip') { return reply.code(400).send({ error: `Unbekanntes Paket-Format: ${manifest.format}` }); } } catch { return reply.code(400).send({ error: 'manifest.json ist kein valides JSON' }); } } // Ordner mit ID-Mapping importieren (Eltern zuerst): const folderIdMap = new Map(); const sortedFolders = [...(pkg.folders ?? [])].sort((a, b) => { if (a.parent_id === null) return -1; if (b.parent_id === null) return 1; if (a.parent_id === b.parent_id) return 0; const aRes = folderIdMap.has(a.parent_id!) ? -1 : 0; return aRes; }); for (const f of sortedFolders) { const newParent = f.parent_id ? folderIdMap.get(f.parent_id) ?? null : null; const created = db.createGlobalFolder({ name: f.name, parent_id: newParent }); folderIdMap.set(f.id, created.id); } // Blöcke importieren; Thumbnail aus thumbs/ lesen wenn referenziert: let imported = 0; for (const b of pkg.blocks) { let svgData: string | null = null; if (b.thumbnail) { const thumbFile = zip.file(b.thumbnail); if (thumbFile) { try { svgData = await thumbFile.async('string'); } catch { svgData = null; } } } const newFolderId = b.folder_id ? folderIdMap.get(b.folder_id) ?? null : null; db.createGlobalBlock({ name: b.name, folder_id: newFolderId, block_data: b.block_data, svg_data: svgData, }); imported++; } return reply.code(201).send({ imported_blocks: imported, imported_folders: folderIdMap.size }); }); }