Batch-Uebermittlung (Phase 1): Collection + POST /v1/batch

Alternativer Uebermittlungsweg neben /v1/orders (unveraendert). Neue
Collection batch_submissions (Empfaengerschema Firma/Vorname/Nachname/
Zeile1-3/PLZ/Stadt/Land + Text ODER Druck-PDF, je Kunde ueber api_client).
Neuer Endpunkt POST /v1/batch: ein Empfaenger je Aufruf, PDF via
pdf_file_id (aus /v1/files) oder pdf_base64, oder text; Zeichen-Warnung
wie /v1/orders. Service-Policy darf batch_submissions lesen/als gebuendelt
markieren. Buendelung (xlsx+PDF+FTP) folgt in Phase 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
s4luorth
2026-09-28 08:53:31 +02:00
parent 2438ecb491
commit 131c48485c
2 changed files with 95 additions and 0 deletions

View File

@@ -691,6 +691,30 @@ const collections = [
f('notified', 'boolean', { interface: 'boolean', default: false, note: 'Erinnerungs-Mail bereits verschickt (verhindert Wiederholung).' }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
// Alternativer Übermittlungsweg (Lettershop/FTP): je Empfänger ein Datensatz
// im festen Schema + fertige Druck-PDF ODER Schriftstücktext. Wird je Kunde
// täglich (03:00 Folgetag; Sa+So gemeinsam) zu einer xlsx + Sammel-Druck-PDF
// gebündelt und per FTP hochgeladen.
{ collection: 'batch_submissions', meta: { icon: 'inventory', note: 'Alternativer Übermittlungsweg: Empfängerzeile + Druck-PDF/Text; täglich je Kunde zu xlsx+PDF gebündelt (FTP).' }, fields: [
m2o('api_client', 'api_clients', { onDelete: 'CASCADE', note: 'Zugang, über den übermittelt wurde.' }),
m2o('customer', 'directus_users', { note: 'Kunde (Login-Konto) des Zugangs.' }),
f('firma', 'string', { interface: 'input' }),
f('vorname', 'string', { interface: 'input' }),
f('nachname', 'string', { interface: 'input' }),
f('zeile1', 'string', { interface: 'input' }),
f('zeile2', 'string', { interface: 'input' }),
f('zeile3', 'string', { interface: 'input' }),
f('plz', 'string', { interface: 'input' }),
f('stadt', 'string', { interface: 'input' }),
f('land', 'string', { interface: 'input' }),
f('text', 'text', { interface: 'input-multiline', note: 'Schriftstücktext (Handschrift), falls keine Druck-PDF.' }),
m2o('pdf_file', 'directus_files', { note: 'Fertige Druck-PDF (wird 1:1 übernommen), falls kein Text.' }),
dropdown('status', ['pending', 'bundled'], { default: 'pending', note: 'pending = noch nicht gebündelt.' }),
f('bundle_name', 'string', { interface: 'input', readonly: true, note: 'Name der Bündel-Datei, in die dieser Datensatz ging.' }),
f('bundled_at', 'timestamp', { interface: 'datetime', readonly: true }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
];
// ── Schema anlegen ──────────────────────────────────────────────────────────
@@ -1161,6 +1185,10 @@ async function applyPermissions() {
// Hochgeladene Druckdateien lesen (Backend führt PDFs für den Charge-Sammeldruck
// zusammen und lädt sie dafür über /assets).
await ensurePermission(svcPol.id, 'directus_files', 'read');
// Alternativer Übermittlungsweg: Backend liest die Datensätze zum Bündeln und
// markiert sie danach als gebündelt.
await ensurePermission(svcPol.id, 'batch_submissions', 'read');
await ensurePermission(svcPol.id, 'batch_submissions', 'update', ['status', 'bundle_name', 'bundled_at']);
console.log(' Hinweis: Für Service einen Nutzer in Rolle "Service" anlegen und dessen',
'statischen Token in .env von Backend/Produktion eintragen.');

View File

@@ -265,6 +265,73 @@ const handler = (router, { services, getSchema, logger }) => {
}
});
// ── POST /v1/batch ──────────────────────────────────────────────────────────
// Alternativer Übermittlungsweg: EIN Empfänger je Aufruf im festen Schema plus
// ENTWEDER eine fertige Druck-PDF ODER einen Schriftstücktext. Wird gespeichert
// und später je Kunde täglich zu xlsx + Sammel-Druck-PDF gebündelt (FTP).
// Ändert NICHTS an /v1/orders – eigenständiger Endpunkt.
router.post('/v1/batch', async (req, res) => {
const client = await authClient(req);
if (!client) return fehler(res, 401, 'Missing or invalid API key.');
const b = req.body || {};
const feld = (v) => (v == null ? '' : String(v));
const adr = {
firma: feld(b.firma), vorname: feld(b.vorname), nachname: feld(b.nachname),
zeile1: feld(b.zeile1), zeile2: feld(b.zeile2), zeile3: feld(b.zeile3),
plz: feld(b.plz), stadt: feld(b.stadt), land: feld(b.land),
};
if (!Object.values(adr).some((v) => v.trim())) return fehler(res, 400, 'At least one address field is required.');
const text = feld(b.text).trim();
const hatPdf = !!(b.pdf_file_id || b.pdf_base64);
if (!text && !hatPdf) return fehler(res, 400, 'Either a print PDF (pdf_file_id or pdf_base64) or letter text is required.');
if (text && hatPdf) return fehler(res, 400, 'Provide either a PDF or text, not both.');
try {
const schema = await getSchema();
const svc = svcFactory(schema);
const clean = await ladeClean(schema);
// Zeichen-Warnungen (wie /v1/orders): nicht erlaubte Zeichen werden entfernt.
const unerlaubt = new Set();
const pruefe = (s) => { for (const ch of new Set(String(s || ''))) if (clean(ch) === '') unerlaubt.add(ch); };
Object.values(adr).forEach(pruefe);
if (text) pruefe(text);
// PDF: bestehende file_id (aus /v1/files) ODER base64 hier hochladen.
let pdfFileId = null;
if (b.pdf_file_id) {
pdfFileId = String(b.pdf_file_id);
} else if (b.pdf_base64) {
let buf;
try { buf = Buffer.from(String(b.pdf_base64), 'base64'); } catch { return fehler(res, 400, 'pdf_base64 is not valid base64.'); }
if (!buf.length || buf.slice(0, 5).toString('latin1') !== '%PDF-') return fehler(res, 415, 'pdf_base64 is not a valid PDF.');
if (buf.length > 80 * 1024 * 1024) return fehler(res, 413, 'PDF too large (max. 80 MB).');
const filesSvc = new FilesService({ schema, accountability: null });
pdfFileId = await filesSvc.uploadOne(Readable.from(buf), {
storage: STORAGE, filename_download: `batch_${Date.now()}.pdf`, title: 'Batch print PDF', type: 'application/pdf',
});
}
const id = await svc('batch_submissions').createOne({
api_client: client.id, customer: client.customer || null,
firma: clean(adr.firma) || null, vorname: clean(adr.vorname) || null, nachname: clean(adr.nachname) || null,
zeile1: clean(adr.zeile1) || null, zeile2: clean(adr.zeile2) || null, zeile3: clean(adr.zeile3) || null,
plz: clean(adr.plz) || null, stadt: clean(adr.stadt) || null, land: clean(adr.land) || null,
text: text ? clean(text) : null,
pdf_file: pdfFileId,
status: 'pending',
});
const warnings = [];
if (unerlaubt.size) warnings.push({ code: 'unsupported_characters', characters: [...unerlaubt],
message: 'Some characters are not supported and were removed. See the allowed character set in the documentation.' });
return res.status(201).json({ id: String(id), status: 'accepted', ...(warnings.length ? { warnings } : {}) });
} catch (err) {
logger.error(`[skrift-api] batch: ${err.stack || err.message}`);
return fehler(res, 500, 'Submission could not be stored.');
}
});
// ── POST /v1/orders ─────────────────────────────────────────────────────────
router.post('/v1/orders', async (req, res) => {
const client = await authClient(req);