diff --git a/bootstrap/bootstrap.mjs b/bootstrap/bootstrap.mjs index 6f9f7cc..d40eb0a 100644 --- a/bootstrap/bootstrap.mjs +++ b/bootstrap/bootstrap.mjs @@ -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.'); diff --git a/extensions/directus-extension-skrift-api/dist/index.js b/extensions/directus-extension-skrift-api/dist/index.js index 6cbecf4..e9e25f2 100644 --- a/extensions/directus-extension-skrift-api/dist/index.js +++ b/extensions/directus-extension-skrift-api/dist/index.js @@ -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);