This commit is contained in:
seb
2026-10-14 14:45:45 +02:00
parent 37c58648db
commit 90b782e500
7 changed files with 714 additions and 1 deletions

View File

@@ -7,6 +7,7 @@ import * as googleAuth from '../../db/googleAuth.js';
import { clearCachedUser } from '../middleware/auth.js';
import { initCompanyVfs } from '../../tools/vfs.js';
import { executeCompanyImport } from '../../services/companyImport.js';
import { executeCompanyExport } from '../../services/companyExport.js';
// #region Helpers
@@ -605,6 +606,189 @@ export function register(app, basePath = '') {
// #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}: