1020 lines
38 KiB
JavaScript
1020 lines
38 KiB
JavaScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import multer from 'multer';
|
|
import JSZip from 'jszip';
|
|
import env from '../../config/env.js';
|
|
import * as db from '../../db/company.js';
|
|
import { requireCompanyAdmin } from '../../db/companyuser.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' || error.message === 'Invited users cannot create a company') 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'
|
|
* 403:
|
|
* description: Not authorized (invited users cannot create companies when implicit create is disabled)
|
|
* 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 });
|
|
}
|
|
|
|
if (!env.implicitCreate) {
|
|
const isInvited = await db.isInvitedUser(req.user.id);
|
|
if (isInvited) {
|
|
return res.status(403).json({ error: 'Invited users cannot create a company' });
|
|
}
|
|
}
|
|
|
|
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/<companyId>/`, 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`,
|
|
async (req, res, next) => {
|
|
try {
|
|
const companyId = requireSelectedCompany(req);
|
|
await requireCompanyAdmin(req.user.id, companyId);
|
|
next();
|
|
} catch (error) {
|
|
const status = companyErrorStatus(error);
|
|
return res.status(status).json({ error: error.message });
|
|
}
|
|
},
|
|
uploadCompanyImportFile,
|
|
async (req, res) => {
|
|
let companyId;
|
|
try {
|
|
companyId = requireSelectedCompany(req);
|
|
await requireCompanyAdmin(req.user.id, companyId);
|
|
} catch (error) {
|
|
const status = companyErrorStatus(error);
|
|
return res.status(status).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, req.user.id);
|
|
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, req.user.id);
|
|
clearCompanyPrinters(parsedId.companyId);
|
|
res.sendStatus(204);
|
|
} catch (error) {
|
|
const status = companyErrorStatus(error);
|
|
if (status !== 500) {
|
|
return res.status(status).json({ error: error.message });
|
|
}
|
|
console.error('Delete company failed:', error);
|
|
res.status(500).json({ error: 'Failed to delete company' });
|
|
}
|
|
});
|
|
|
|
// #endregion
|
|
}
|