import fs from 'fs'; import path from 'path'; import multer from 'multer'; import JSZip from 'jszip'; import * as db from '../../db/company.js'; import * as googleAuth from '../../db/googleAuth.js'; import { clearCachedUser } from '../middleware/auth.js'; import { emit } from '../sse.js'; import { initCompanyVfs } from '../../tools/vfs.js'; import { createDefaultLabelTemplates } from '../../services/defaultLabelTemplates.js'; import { executeCompanyImport } from '../../services/companyImport.js'; import { executeCompanyExport } from '../../services/companyExport.js'; import { clearCompanyPrinters } from '../../services/companyPrinterService.js'; // #region Helpers const uploadZip = multer({ storage: multer.memoryStorage(), limits: { fileSize: 100 * 1024 * 1024 }, // 100 MB fileFilter: (_req, file, cb) => { const name = file.originalname?.toLowerCase() || ''; const isZipExt = name.endsWith('.zip'); const isZipMime = file.mimetype === 'application/zip' || file.mimetype === 'application/x-zip-compressed'; if (isZipExt || isZipMime) { cb(null, true); } else { cb(new Error('Only .zip archives are accepted')); } }, }).single('file'); function uploadCompanyImportFile(req, res, next) { uploadZip(req, res, (err) => { if (err) { if (err.code === 'LIMIT_FILE_SIZE') { return res.status(413).json({ error: 'File too large (max 100 MB)' }); } return res.status(400).json({ error: err.message }); } next(); }); } function userPayload(authRow) { return { id: authRow.id, email: authRow.email, selectedCompanyId: authRow.selectedCompanyId, }; } function parseCompanyBody(body) { const { iso639, name, taxrate, reducedtaxrate } = body ?? {}; if (!iso639 || !name || taxrate == null || reducedtaxrate == null) { return { error: 'iso639, name, taxrate, and reducedtaxrate are required' }; } if (typeof taxrate !== 'number' || !Number.isFinite(taxrate) || taxrate < 0) { return { error: 'taxrate must be a non-negative number' }; } if (typeof reducedtaxrate !== 'number' || !Number.isFinite(reducedtaxrate) || reducedtaxrate < 0) { return { error: 'reducedtaxrate must be a non-negative number' }; } return { iso639, name, taxrate, reducedtaxrate }; } function parseCompanyId(id) { const companyId = Number(id); if (!Number.isInteger(companyId) || companyId <= 0) { return { error: 'Invalid company id' }; } return { companyId }; } function requireSelectedCompany(req) { const id = req.user?.selectedCompanyId; if (id == null) { const err = new Error('No company selected'); err.statusCode = 400; throw err; } return id; } function companyErrorStatus(error) { if (error.message === 'Company not found') return 404; if (error.message === 'Company name not available') return 409; if (error.message === 'Invalid tax rate' || error.message === 'Text required' || error.message === 'No company selected') return 400; if (error.message === 'Not authorized') return 403; return 500; } // #endregion /** * Registers company CRUD routes (requires auth middleware). * @param {import('express').Application} app - Express app * @param {string} [basePath=''] - Optional URL prefix for all routes in this module */ export function register(app, basePath = '') { /** * @openapi * /company: * get: * tags: [Company] * summary: List companies * description: Lists companies for the authenticated user. * security: * - cookieAuth: [] * x-markdown: company/list.md * responses: * 200: * description: Company list * content: * application/json: * schema: * type: object * properties: * companies: * type: array * items: * $ref: '#/components/schemas/Company' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 500: * description: Server error * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * post: * tags: [Company] * summary: Create company * description: Creates a company and selects it for the current user. * security: * - cookieAuth: [] * x-markdown: company/create.md * x-signals: [user, company] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [iso639, name, taxrate, reducedtaxrate] * properties: * iso639: * type: string * name: * type: string * taxrate: * type: number * reducedtaxrate: * type: number * example: * iso639: de * name: Acme GmbH * taxrate: 19 * reducedtaxrate: 7 * responses: * 201: * description: Created; selected company updated * content: * application/json: * schema: * type: object * properties: * company: * $ref: '#/components/schemas/Company' * user: * $ref: '#/components/schemas/User' * 400: * description: Validation error * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region GET /company app.get(`${basePath}/company`, async (req, res) => { try { const companies = await db.listCompanies(req.user.id); res.status(200).json({ companies }); } catch (error) { console.error('List companies failed:', error); res.status(500).json({ error: 'Failed to list companies' }); } }); // #endregion // #region POST /company app.post(`${basePath}/company`, async (req, res) => { const body = parseCompanyBody(req.body); if (body.error) { return res.status(400).json({ error: body.error }); } try { const company = await db.createCompany(req.user.id, body); await googleAuth.setSelectedCompany(req.user.id, company.id); clearCachedUser(req.user.id); try { await initCompanyVfs({ googleAuthId: req.user.id }); } catch (vfsErr) { console.error('Failed to initialize company VFS:', vfsErr); } try { await createDefaultLabelTemplates(req.user.id); } catch (tplErr) { console.error('Failed to create default label templates:', tplErr); } const authRow = await googleAuth.getAllowedEmail(req.user.email); res.status(201).json({ company, user: userPayload(authRow) }); } catch (error) { const status = companyErrorStatus(error); if (status === 500) console.error('Create company failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /company/history: * get: * tags: [Company] * summary: Get company history * description: | * Returns `companyhistory` rows for the selected company, newest first (`date` DESC, `id` DESC). * security: * - cookieAuth: [] * x-markdown: company/history.md * parameters: * - in: query * name: limit * required: false * schema: * type: integer * minimum: 1 * maximum: 1000 * description: Maximum number of entries to return (newest first). Omit for all entries. * responses: * 200: * description: Company history (newest first) * content: * application/json: * schema: * type: object * required: [history, truncated] * properties: * history: * type: array * items: * $ref: '#/components/schemas/CompanyHistoryEntry' * truncated: * type: boolean * description: True when a limit was applied and the result set was exactly that size (more entries may exist). * 400: * description: Invalid limit or no company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Forbidden * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 500: * description: Server error * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * post: * tags: [Company] * summary: Add company history entry * description: | * Appends an entry to `companyhistory` for the selected company. * security: * - cookieAuth: [] * x-markdown: company/addHistory.md * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [text] * properties: * text: * type: string * description: History entry text or note * responses: * 201: * description: History entry created * content: * application/json: * schema: * type: object * required: [history] * properties: * history: * $ref: '#/components/schemas/CompanyHistoryEntry' * 400: * description: Text required or no company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Forbidden * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 500: * description: Server error * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region GET /company/history app.get(`${basePath}/company/history`, async (req, res) => { try { requireSelectedCompany(req); } catch (error) { return res.status(error.statusCode || 400).json({ error: error.message }); } const rawLimit = req.query.limit; let limit = null; if (rawLimit !== undefined) { limit = parseInt(rawLimit, 10); if (!Number.isInteger(limit) || limit < 1 || limit > 1000) { return res.status(400).json({ error: 'limit must be an integer between 1 and 1000' }); } } try { const history = await db.getCompanyHistory(req.user.id, limit != null ? { limit } : undefined); const truncated = limit != null && history.length === limit; res.status(200).json({ history, truncated }); } catch (error) { const status = companyErrorStatus(error); if (status === 500) console.error('Get company history failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion // #region POST /company/history app.post(`${basePath}/company/history`, async (req, res) => { try { requireSelectedCompany(req); } catch (error) { return res.status(error.statusCode || 400).json({ error: error.message }); } const text = typeof req.body?.text === 'string' ? req.body.text.trim() : ''; if (!text) { return res.status(400).json({ error: 'text is required' }); } try { const entry = await db.addCompanyHistory(req.user.id, { text }); res.status(201).json({ history: entry }); } catch (error) { const status = companyErrorStatus(error); if (status === 500) console.error('Add company history failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /company/import: * post: * tags: [Company] * summary: Import company catalog from zip archive (SSE) * description: | * Uploads a .zip archive containing company export data (categories, articles, pictures), * preserves archive contents under `data/import//`, and executes full catalog * import (categories hierarchy, articles upsert, variant linking, image processing). * Streams progress events via Server-Sent Events (`text/event-stream`). * security: * - cookieAuth: [] * x-markdown: company/import.md * requestBody: * required: false * content: * multipart/form-data: * schema: * type: object * properties: * file: * type: string * format: binary * description: ZIP archive (.zip) containing company data * responses: * 200: * description: Import progress stream (`text/event-stream`) or JSON result * content: * text/event-stream: * schema: * type: string * application/json: * schema: * type: object * required: [ok, message] * properties: * ok: * type: boolean * example: true * message: * type: string * example: Import erfolgreich abgeschlossen. * stats: * type: object * 400: * description: Bad request (missing file, not a zip, or no company selected) * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 500: * description: Server error * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region POST /company/import const activeImportsByCompany = new Set(); /** Signals for data the catalog import may create/modify (emitted even after a failed, partially applied run). */ const IMPORT_SIGNALS = ['category', 'articles', 'brand', 'attributename', 'warehouse', 'carrier', 'supplier', 'order', 'shipment', 'stock', 'ledger']; const emitImportSignals = (companyId) => { for (const signal of IMPORT_SIGNALS) { emit(signal, signal === 'brand' ? { companyId, data: { sender: '', brandId: null } } : { companyId }); } }; app.post( `${basePath}/company/import`, uploadCompanyImportFile, async (req, res) => { let companyId; try { companyId = requireSelectedCompany(req); } catch (error) { return res.status(error.statusCode || 400).json({ error: error.message }); } if (activeImportsByCompany.has(companyId)) { return res.status(409).json({ error: 'Import already in progress for this company' }); } activeImportsByCompany.add(companyId); try { const importDir = path.resolve(process.cwd(), 'data/import', String(companyId)); // If a file was uploaded, unpack and preserve it if (req.file?.buffer) { const originalName = req.file.originalname || 'import.zip'; if (!originalName.toLowerCase().endsWith('.zip')) { return res.status(400).json({ error: 'Uploaded file must have a .zip extension' }); } let zip; try { zip = await JSZip.loadAsync(req.file.buffer); } catch (err) { return res.status(400).json({ error: 'Invalid or corrupt zip archive' }); } try { // Do not clean the import upload directory so files remain available for inspection/analysis // fs.rmSync(importDir, { recursive: true, force: true }); fs.mkdirSync(importDir, { recursive: true }); // Preserve the raw zip archive fs.writeFileSync(path.join(importDir, 'import.zip'), req.file.buffer); console.log(`[Company Import] Extracting archive "${originalName}" into ${importDir}:`); let fileCount = 0; let dirCount = 0; for (const [relativePath, entry] of Object.entries(zip.files)) { const safePath = path.normalize(relativePath).replace(/^(\.\.(\/|\\|$))+/, ''); const destPath = path.resolve(importDir, safePath); if (!destPath.startsWith(importDir)) { console.warn(`[Company Import] Skipped unsafe path: ${relativePath}`); continue; } if (entry.dir) { dirCount++; fs.mkdirSync(destPath, { recursive: true }); } else { fileCount++; fs.mkdirSync(path.dirname(destPath), { recursive: true }); const content = await entry.async('nodebuffer'); fs.writeFileSync(destPath, content); } } console.log(`[Company Import] Preserved ${fileCount} files and ${dirCount} directories in ${importDir}`); } catch (err) { console.error('[Company Import] Failed to extract archive:', err); return res.status(500).json({ error: 'Failed to extract import archive' }); } } else { // No file uploaded in this request; check if existing preserved files are present const catFile = path.join(importDir, 'categories.csv'); const artFile = path.join(importDir, 'articles.csv'); if (!fs.existsSync(catFile) || !fs.existsSync(artFile)) { return res.status(400).json({ error: 'Zip file is required in "file" form field' }); } } const wantsSse = req.headers.accept?.includes('text/event-stream') || req.query.stream === '1' || req.query.sse === '1' || !req.headers.accept?.includes('application/json'); if (wantsSse) { req.socket?.setTimeout(0); res.setTimeout?.(0); req.socket?.setNoDelay(true); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', }); if (typeof res.flushHeaders === 'function') { res.flushHeaders(); } // Initial ping to establish frame through any proxy res.write(': ping\n\n'); req.on('close', () => { console.warn(`[Company Import SSE] Client request closed (writableEnded: ${res.writableEnded}, destroyed: ${res.destroyed})`); }); res.on('close', () => { if (!res.writableEnded) { console.warn(`[Company Import SSE] Response stream closed (destroyed: ${res.destroyed})`); } }); const sseWrite = (event, data) => { if (res.writableEnded || res.destroyed) { console.warn(`[Company Import SSE DROP] event="${event}" dropped: connection closed (destroyed: ${res.destroyed}, ended: ${res.writableEnded})`); return; } if (event === 'progress') { console.log(`[Company Import SSE] progress cat=${data.categoriesdone}/${data.categoriestotal} art=${data.articlesdone}/${data.articlestotal} img=${data.imagesdone}/${data.imagestotal} rec=${data.receiptsdone}/${data.receiptstotal}`); } else if (event === 'images') { console.log(`[Company Import SSE] images batch count=${Array.isArray(data) ? data.length : 1}`); } else { console.log(`[Company Import SSE] event="${event}"`, data); } res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); if (typeof res.flush === 'function') res.flush(); if (typeof req.socket?.uncork === 'function') req.socket.uncork(); }; sseWrite('status', { message: 'Import gestartet. Daten werden verarbeitet...' }); try { const outcome = await executeCompanyImport({ googleAuthId: req.user.id, companyId, importDir, onProgress: (counts) => { sseWrite('progress', counts); }, onImages: (skelehashes) => { sseWrite('images', skelehashes); }, }); sseWrite('done', outcome); res.end(); } catch (err) { console.error('[Company Import] Import execution failed:', err); sseWrite('error', { message: err.message || 'Import fehlgeschlagen' }); res.end(); } } else { try { const outcome = await executeCompanyImport({ googleAuthId: req.user.id, companyId, importDir, onProgress: (counts) => { console.log('[Import Progress]', counts); }, }); res.status(200).json(outcome); } catch (err) { console.error('[Company Import] Import execution failed:', err); res.status(500).json({ error: err.message || 'Import fehlgeschlagen' }); } } } finally { activeImportsByCompany.delete(companyId); emitImportSignals(companyId); } } ); // #endregion /** * @openapi * /company/export: * get: * tags: [Company] * summary: Export company catalog as zip archive (SSE) * description: | * Exports full company catalog data (categories hierarchy, articles, variants, brands, pictures) * into a round-trip compatible .zip archive matching the format of `/company/import`. * Streams progress events via Server-Sent Events (`text/event-stream`), followed by base64-encoded * binary chunks in `chunk` events and a final `done` event. * security: * - cookieAuth: [] * x-markdown: company/export.md * parameters: * - in: query * name: stream * schema: * type: string * enum: ['0', '1'] * description: Force SSE stream mode (`1`) or direct binary download (`0`) * responses: * 200: * description: Export progress stream (`text/event-stream`) with binary chunks, or raw ZIP file * content: * text/event-stream: * schema: * type: string * application/zip: * schema: * type: string * format: binary * 400: * description: Bad request (no company selected) * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 500: * description: Server error * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * post: * tags: [Company] * summary: Export company catalog as zip archive (SSE) * description: POST variant of `/company/export` with identical SSE streaming semantics. * security: * - cookieAuth: [] * responses: * 200: * description: Export progress stream (`text/event-stream`) with binary chunks, or raw ZIP file * content: * text/event-stream: * schema: * type: string * application/zip: * schema: * type: string * format: binary * 400: * description: Bad request (no company selected) * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 500: * description: Server error * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region GET/POST /company/export const handleCompanyExport = async (req, res) => { let companyId; try { companyId = requireSelectedCompany(req); } catch (error) { return res.status(error.statusCode || 400).json({ error: error.message }); } const wantsSse = req.headers.accept?.includes('text/event-stream') || req.query.stream === '1' || req.query.sse === '1' || (!req.headers.accept?.includes('application/zip') && !req.path.endsWith('.zip') && req.query.download !== '1'); if (wantsSse) { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); if (typeof res.flushHeaders === 'function') { res.flushHeaders(); } const sseWrite = (event, data) => { if (res.writableEnded || res.destroyed) return; const payload = typeof data === 'string' ? data : JSON.stringify(data); res.write(`event: ${event}\ndata: ${payload}\n\n`); if (typeof res.flush === 'function') res.flush(); }; sseWrite('status', { message: 'Export gestartet. Daten werden verarbeitet...' }); try { const outcome = await executeCompanyExport({ googleAuthId: req.user.id, companyId, onStatus: (status) => sseWrite('status', status), onProgress: (counts) => sseWrite('progress', counts), }); // Stream binary chunks as base64 in SSE 'chunk' events const CHUNK_SIZE = 64 * 1024; // 64 KB binary slice per SSE event const { zipBuffer, filename, stats } = outcome; const totalChunks = Math.ceil(zipBuffer.length / CHUNK_SIZE); sseWrite('status', { message: `Sende Archiv (${(zipBuffer.length / (1024 * 1024)).toFixed(2)} MB in ${totalChunks} Chunks)...`, }); for (let i = 0; i < totalChunks; i++) { if (res.writableEnded || res.destroyed) break; const slice = zipBuffer.subarray(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE); const base64 = slice.toString('base64'); res.write(`event: chunk\ndata: ${base64}\n\n`); if (typeof res.flush === 'function') res.flush(); } sseWrite('done', { ok: true, filename, size: zipBuffer.length, totalChunks, stats, }); res.end(); } catch (err) { console.error('[Company Export] Export execution failed:', err); sseWrite('error', { message: err.message || 'Export fehlgeschlagen' }); res.end(); } } else { // Direct binary download try { const outcome = await executeCompanyExport({ googleAuthId: req.user.id, companyId, }); res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', `attachment; filename="${outcome.filename}"`); res.status(200).send(outcome.zipBuffer); } catch (err) { console.error('[Company Export] Export failed:', err); const status = companyErrorStatus(err); res.status(status).json({ error: err.message || 'Export fehlgeschlagen' }); } } }; app.get(`${basePath}/company/export`, handleCompanyExport); app.get(`${basePath}/company/export.zip`, handleCompanyExport); app.post(`${basePath}/company/export`, handleCompanyExport); // #endregion /** * @openapi * /company/{id}: * put: * tags: [Company] * summary: Update company * description: Updates a company. * security: * - cookieAuth: [] * x-markdown: company/update.md * x-signals: [company] * parameters: * - in: path * name: id * required: true * schema: * type: integer * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [iso639, name, taxrate, reducedtaxrate] * properties: * iso639: * type: string * name: * type: string * taxrate: * type: number * reducedtaxrate: * type: number * responses: * 200: * description: Updated * content: * application/json: * schema: * type: object * properties: * company: * $ref: '#/components/schemas/Company' * 400: * description: Validation error * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Company not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * delete: * tags: [Company] * summary: Delete company * description: Deletes a company. * security: * - cookieAuth: [] * x-markdown: company/delete.md * x-signals: [user, company] * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 204: * description: Deleted * 400: * description: Invalid id * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 500: * description: Server error * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region PUT /company/:id app.put(`${basePath}/company/:id`, async (req, res) => { const parsedId = parseCompanyId(req.params.id); if (parsedId.error) { return res.status(400).json({ error: parsedId.error }); } const body = parseCompanyBody(req.body); if (body.error) { return res.status(400).json({ error: body.error }); } try { const company = await db.updateCompany(parsedId.companyId, body); emit('company', { companyId: parsedId.companyId, data: { companyId: parsedId.companyId } }); res.status(200).json({ company }); } catch (error) { const status = companyErrorStatus(error); if (status === 500) console.error('Update company failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion // #region DELETE /company/:id app.delete(`${basePath}/company/:id`, async (req, res) => { const parsedId = parseCompanyId(req.params.id); if (parsedId.error) { return res.status(400).json({ error: parsedId.error }); } try { await db.deleteCompany(parsedId.companyId); clearCompanyPrinters(parsedId.companyId); res.sendStatus(204); } catch (error) { console.error('Delete company failed:', error); res.status(500).json({ error: 'Failed to delete company' }); } }); // #endregion }