diff --git a/extensions/directus-extension-skrift-api/dist/index.js b/extensions/directus-extension-skrift-api/dist/index.js index e9e25f2..9d3b417 100644 --- a/extensions/directus-extension-skrift-api/dist/index.js +++ b/extensions/directus-extension-skrift-api/dist/index.js @@ -27,6 +27,9 @@ const EXT_DIR = path.dirname(fileURLToPath(import.meta.url)); const BACKEND_URL = (process.env.SKRIFT_BACKEND_URL || 'http://skrift-backend:4000').replace(/\/$/, ''); const BACKEND_TOKEN = process.env.SKRIFT_BACKEND_TOKEN || ''; +// Geheimer (kryptischer) Pfad-Token für die öffentliche Versand-Bestätigungsseite. +// Ohne gesetzten Wert ist die Seite aus (404). URL: /v1/versand/ +const VERSAND_TOKEN = (process.env.SKRIFT_VERSAND_TOKEN || '').trim(); const STORAGE = (process.env.STORAGE_LOCATIONS || 'local').split(',')[0].trim() || 'local'; const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex'); @@ -177,6 +180,34 @@ const DOCS_HTML = ` { "file_id": "..." } +

Batch submission · alternative delivery

+
+
POST /v1/batch
+

Alternative to /v1/orders for a lettershop workflow: submit one recipient per call in a fixed schema, plus either a ready print PDF (used as-is) or letter text. Submissions are collected and bundled per API key, once a day (03:00 the next morning; Sat+Sun together) into one .xlsx (the schema below) and one merged print .pdf, then uploaded to the configured FTP target.

+

Request body

+ + + + + + + + + + + + +
FieldTypeDescription
firmastringCompany (optional).
vornamestringFirst name.
nachnamestringLast name.
zeile1, zeile2, zeile3stringFree address lines.
plzstringPostal code.
stadtstringCity.
landstringCountry.
pdf_file_idstringA file_id from POST /v1/files — the ready print PDF (used 1:1). Provide this or pdf_base64 or text.
pdf_base64stringAlternatively the PDF inline as base64.
textstringAlternatively a letter text (handwriting); not part of the FTP bundle.
+

At least one address field is required, and exactly one of pdf_file_id / pdf_base64 / text.

+
curl -X POST https://dev.skrift.de/v1/batch \\
+  -H "X-Api-Key: <KEY>" -H "Content-Type: application/json" \\
+  -d '{"firma":"Muster GmbH","vorname":"Beate","nachname":"Schütte",
+       "zeile1":"Industriestraße 28","plz":"21493","stadt":"Schwarzenbek","land":"Deutschland",
+       "pdf_file_id":"<file_id>"}'
+
+{ "id": "123", "status": "accepted" }
+
+

Machine-readable specification: OpenAPI (JSON) — import into Postman, Insomnia, or any OpenAPI client.

`; @@ -332,6 +363,82 @@ const handler = (router, { services, getSchema, logger }) => { } }); + // ── Öffentliche Versand-Bestätigung (kryptische URL, ohne Login) ──────────── + // /v1/versand/ : Auftragsnummer eingeben → Auftrag wird als + // „versendet" markiert (Status-API liefert dann „shipped"). Schutz = geheimer + // Pfad-Token (kein Konto). Ohne gesetzten Token ist die Seite aus. + const escH = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + const VS_LABEL = { eingegangen: 'Eingegangen', im_druck: 'Im Druck', kuvertieren: 'Kuvertierung', versandfertig: 'Versandfertig' }; + const versandSeite = ({ orders = [], message = '', error = '' } = {}) => { + const rows = orders.length ? orders.map((o) => ` +
+
${escH(o.order_number)}${escH(VS_LABEL[o.production_status] || o.production_status || 'offen')} · ${Number(o.entries_count) || 1} Empf. · ${escH(String(o.date_created || '').slice(0, 10))}
+
+
`).join('') : '

Keine offenen Aufträge – alles versendet.

'; + return ` +Versand bestätigen +
+

Versand bestätigen

+

Alle offenen Aufträge. Bitte pro Auftrag „Versendet" klicken, sobald er verschickt ist.

+ ${error ? `
${escH(error)}
` : ''} + ${message ? `
${escH(message)}
` : ''} + ${rows} +
`; + }; + + // Offene Aufträge (noch nicht versendet/storniert) – nur Nummer/Status/Menge/Datum. + async function ladeOffeneAuftraege(svc) { + return svc('orders').readByQuery({ + filter: { production_status: { _nin: ['versendet', 'storniert'] }, status: { _neq: 'entwurf' } }, + sort: ['-date_created'], limit: 500, + fields: ['id', 'order_number', 'production_status', 'entries_count', 'date_created'], + }); + } + + router.get('/v1/versand/:token', async (req, res) => { + if (!VERSAND_TOKEN || req.params.token !== VERSAND_TOKEN) return res.status(404).type('html').send('Not found.'); + try { + const svc = svcFactory(await getSchema()); + return res.type('html').send(versandSeite({ orders: (await ladeOffeneAuftraege(svc)) || [] })); + } catch (e) { + logger.error(`[skrift-api] versand list: ${e.message}`); + return res.type('html').send(versandSeite({ error: 'Aufträge konnten nicht geladen werden.' })); + } + }); + + router.post('/v1/versand/:token', async (req, res) => { + if (!VERSAND_TOKEN || req.params.token !== VERSAND_TOKEN) return res.status(404).type('html').send('Not found.'); + const nr = String((req.body && (req.body.order_number ?? req.body.nummer)) || req.query.order_number || '').trim(); + try { + const svc = svcFactory(await getSchema()); + let message = '', error = ''; + if (!nr) { + error = 'Bitte einen Auftrag wählen.'; + } else { + const rows = await svc('orders').readByQuery({ filter: { order_number: { _eq: nr } }, limit: 1, fields: ['id', 'order_number', 'production_status'] }); + const order = rows?.[0]; + if (!order) error = `Auftrag ${nr} wurde nicht gefunden.`; + else if (order.production_status === 'versendet') message = `Auftrag ${nr} war bereits als versendet markiert.`; + else if (order.production_status === 'storniert') error = `Auftrag ${nr} ist storniert.`; + else { await svc('orders').updateOne(order.id, { production_status: 'versendet', versandt_am: new Date().toISOString() }); message = `Auftrag ${nr} als versendet markiert.`; } + } + return res.type('html').send(versandSeite({ orders: (await ladeOffeneAuftraege(svc)) || [], message, error })); + } catch (e) { + logger.error(`[skrift-api] versand: ${e.stack || e.message}`); + return res.type('html').send(versandSeite({ error: 'Konnte nicht gespeichert werden. Bitte erneut versuchen.' })); + } + }); + // ── POST /v1/orders ───────────────────────────────────────────────────────── router.post('/v1/orders', async (req, res) => { const client = await authClient(req); @@ -652,6 +759,22 @@ const handler = (router, { services, getSchema, logger }) => { responses: { 200: { description: 'Uploaded', content: { 'application/json': { schema: { type: 'object', properties: { file_id: { type: 'string' } } } } } } }, }, }, + '/v1/batch': { + post: { + summary: 'Batch submission (alternative delivery)', + description: 'One recipient per call in a fixed schema plus either a ready print PDF (pdf_file_id/pdf_base64) or letter text. Bundled per API key once a day into an .xlsx + merged print .pdf and uploaded to FTP.', + requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', + properties: { + firma: { type: 'string' }, vorname: { type: 'string' }, nachname: { type: 'string' }, + zeile1: { type: 'string' }, zeile2: { type: 'string' }, zeile3: { type: 'string' }, + plz: { type: 'string' }, stadt: { type: 'string' }, land: { type: 'string' }, + pdf_file_id: { type: 'string', description: 'file_id from POST /v1/files (ready print PDF, used 1:1).' }, + pdf_base64: { type: 'string', description: 'Alternatively the PDF inline as base64.' }, + text: { type: 'string', description: 'Alternatively a letter text (handwriting); not part of the FTP bundle.' }, + } } } } }, + responses: { 201: { description: 'Accepted', content: { 'application/json': { schema: { type: 'object', properties: { id: { type: 'string' }, status: { type: 'string' }, warnings: { type: 'array', items: { type: 'object' } } } } } } } }, + }, + }, }, };