/** * Skrift – Public-API (v1) für Kunden * =========================================================================== * Eigener, versionierter API-Namespace, gedacht hinter einer eigenen Domain * (z. B. api.skrift.de → NPM proxyt auf /skrift-api/*). Der Kunde sieht NIE das * Directus-Dashboard. * * POST /v1/orders – Auftrag anlegen (Brief/Postkarte: Text ODER Datei) * GET /v1/orders/:nummer – Status abfragen (wartend | in_bearbeitung | versendet) * POST /v1/files – Druckdatei hochladen (Base64) → file_id * GET /v1/openapi.json – OpenAPI-Spezifikation * GET /docs – interaktive Doku (Scalar) * POST /v1/clients – (Admin) neuen API-Zugang + Key erzeugen * * Auth: Header `X-Api-Key: ` (NICHT Authorization – den reserviert Directus). * Gespeichert wird nur der SHA-256-Hash (api_clients). Jeder Zugang ist an GENAU * EIN Produkt gebunden. */ import crypto from 'node:crypto'; import { Readable } from 'node:stream'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; // Verzeichnis dieser Extension (für die selbst gehosteten Swagger-UI-Assets). 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'); const pad3 = (n) => String(n).padStart(3, '0'); const WOCHENTAGE = ['montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonntag']; /** * Internal order → public status (pending | processing | shipped | cancelled). * Der Betreiber steuert im Produktions-Board `production_status`; der kaufmännische * `status` dient als Rückfall. Beide werden berücksichtigt. */ function kundenStatus(order) { const ps = String(order?.production_status || ''); const s = String(order?.status || ''); if (ps === 'storniert' || s === 'storniert') return 'cancelled'; if (ps === 'versendet' || s === 'versendet' || s === 'abgeschlossen') return 'shipped'; if (['im_druck', 'kuvertieren', 'versandfertig'].includes(ps) || ['in_produktion', 'gedruckt'].includes(s)) return 'processing'; return 'pending'; } // Selbst ausgelieferte HTML-Doku (kein externes Script → CSP-sicher). Inline-CSS. const DOCS_HTML = ` Skrift API Reference

Skrift API Reference

Handwritten mail as a service · API v1 · Base URL https://dev.skrift.de

Overview

REST API over HTTPS. Request and response bodies are JSON (Content-Type: application/json, UTF-8). An order yields one document and/or one envelope per recipient; the quantity is derived from the number of recipients. One or more products are bound to your API key; each order selects one via the product field.

Authentication

Every request must include your API key in the X-Api-Key header:

X-Api-Key: <API_KEY>

Each key is scoped to exactly one product. Keys are secrets — never expose them in client-side code.

Errors

Errors use standard HTTP status codes and a uniform body:

{ "error": "human-readable message" }
StatusMeaning
200 / 201Success / order created
400Invalid request (missing or invalid fields)
401Missing or invalid API key
404Resource not found
413Payload too large (file > 80 MB)
415Unsupported media type (only PDF is accepted)
500Internal error

Order status

The status field returns one of:

pending received, not yet in production processing being written / printed shipped dispatched cancelled cancelled

Create an order

POST /v1/orders

Creates an order and returns its order number.

Request body

FieldTypeDescription
productstringProduct key to use. Required if your key allows multiple products; optional (auto) if exactly one.
recipientsarrayRequired. One entry per recipient; count = quantity. Address as free_text (up to 5 lines, separated by \\n).
document_formatstringDocument format: a4, a6h or a6l.
fontstringtilda, alva or ellie.
realisticbooleanNatural handwriting variation. Default true.
shipping_typestringbulk (consolidated shipment to you; default) or single (direct to each recipient).
shipping_daystringPreferred dispatch weekday, e.g. montag (optional).
letter.textstringLetter body, fully written out — no placeholders. Long text flows across multiple pages automatically; [[seitenumbruch]] forces a page break.
letter.filesarrayfile_id(s) from POST /v1/files (file / PDF products).
envelope.modestringnone (no envelope) or recipient (recipient address on the envelope). Envelope size is selected automatically: A4 → DIN Lang, otherwise C6.

Example — text letter

curl -X POST https://dev.skrift.de/v1/orders \\
  -H "X-Api-Key: <KEY>" -H "Content-Type: application/json" \\
  -d '{
    "product":"briefe","document_format":"a4","font":"tilda","realistic":true,
    "shipping_type":"bulk","shipping_day":"montag",
    "letter":{"text":"Dear Anna,\\n\\nthank you for ..."},
    "envelope":{"mode":"recipient"},
    "recipients":[
      {"free_text":"Anna Meier\\nMusterstr. 1\\n12345 Musterstadt"}
    ]
  }'

Example — print uploaded PDFs (file product)

curl -X POST https://dev.skrift.de/v1/orders \\
  -H "X-Api-Key: <KEY>" -H "Content-Type: application/json" \\
  -d '{"document_format":"a4","letter":{"files":["<file_id>"]},
       "envelope":{"mode":"recipient"},
       "recipients":[{"free_text":"Anna Meier\\nMusterstr. 1\\n12345 Musterstadt"}]}'

Example — address envelopes only

curl -X POST https://dev.skrift.de/v1/orders \\
  -H "X-Api-Key: <KEY>" -H "Content-Type: application/json" \\
  -d '{"envelope":{"mode":"recipient"},
       "recipients":[{"free_text":"Anna Meier\\nMusterstr. 1\\n12345 Musterstadt"}]}'

Response · 201

{ "order_number": "18-08-26-001", "status": "pending" }

Retrieve order status

GET /v1/orders/{order_number}

Returns the current status of one of your orders. status_since is the ISO 8601 timestamp (UTC) since which the order has been in its current status.

curl https://dev.skrift.de/v1/orders/18-08-26-001 -H "X-Api-Key: <KEY>"

{ "order_number":"18-08-26-001", "status":"shipped", "status_since":"2026-08-20T09:14:00.000Z", "recipients":1 }

Upload a PDF

POST /v1/files

Uploads a base64-encoded print file. PDF only, max. 80 MB. Returns a file_id to reference in letter.files.

curl -X POST https://dev.skrift.de/v1/files \\
  -H "X-Api-Key: <KEY>" -H "Content-Type: application/json" \\
  -d '{"filename":"letter.pdf","content_base64":"<BASE64>"}'

{ "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.

`; // Swagger UI (selbst gehostet). Assets liegen als swagger-ui.css + swagger-ui-bundle.js // im Extension-Ordner; init.js wird generiert. Kein CDN → CSP-/Brave-sicher. const SWAGGER_HTML = ` Skrift API Reference
`; const INIT_JS = "window.addEventListener('DOMContentLoaded',function(){window.ui=SwaggerUIBundle({url:'v1/openapi.json',dom_id:'#swagger-ui',deepLinking:true,presets:[SwaggerUIBundle.presets.apis],layout:'BaseLayout'});});"; const handler = (router, { services, getSchema, logger }) => { const { ItemsService, FilesService } = services; const svcFactory = (schema) => (c) => new ItemsService(c, { schema, accountability: null }); /** Zeichen-Whitelist (wie im übrigen System) als Sanitizer laden. */ async function ladeClean(schema) { try { const ps = await new ItemsService('pricing_settings', { schema, accountability: null }) .readSingleton({ fields: ['char_whitelist'] }); const wl = ps?.char_whitelist; const re = wl ? new RegExp(`[^${wl}\\n\\r\\t]`, 'g') : null; return (v) => (typeof v === 'string' && re ? v.replace(re, '') : v); } catch { return (v) => v; } } /** API-Key aus dem X-Api-Key-Header lesen und den aktiven Zugang finden. * (NICHT Authorization: Bearer – den reserviert Directus für eigene Tokens * und lehnt fremde Werte global mit INVALID_CREDENTIALS ab.) */ async function authClient(req) { const raw = String(req.headers['x-api-key'] || '').trim(); if (!raw) return null; const schema = await getSchema(); const rows = await new ItemsService('api_clients', { schema, accountability: null }).readByQuery({ filter: { key_hash: { _eq: sha256(raw) }, active: { _eq: true } }, limit: 1, fields: ['id', 'name', 'customer', 'webhook_url', 'config', 'product.id', 'product.key', 'product.type', 'product.input_mode', 'products.product.id', 'products.product.key', 'products.product.type', 'products.product.input_mode'], }); const client = rows?.[0] || null; if (client) { // Letzte Nutzung vermerken (best effort, nicht blockierend). new ItemsService('api_clients', { schema, accountability: null }) .updateOne(client.id, { last_used: new Date().toISOString() }).catch(() => {}); } return client; } const fehler = (res, code, msg) => res.status(code).json({ error: msg }); // ── POST /v1/files ───────────────────────────────────────────────────────── // Body: { filename, content_base64 } → { file_id }. NUR PDF (Sicherheit). router.post('/v1/files', async (req, res) => { const client = await authClient(req); if (!client) return fehler(res, 401, 'Missing or invalid API key.'); const { filename, content_base64 } = req.body || {}; if (!filename || !content_base64) return fehler(res, 400, 'filename and content_base64 are required.'); if (!/\.pdf$/i.test(String(filename))) return fehler(res, 415, 'Only PDF files are accepted (filename must end in .pdf).'); let buf; try { buf = Buffer.from(String(content_base64), 'base64'); } catch { return fehler(res, 400, 'content_base64 is not valid base64.'); } if (!buf.length) return fehler(res, 400, 'File is empty.'); if (buf.length > 80 * 1024 * 1024) return fehler(res, 413, 'File too large (max. 80 MB).'); // Inhalts-Prüfung: echte PDF-Signatur, nicht nur die Endung. if (buf.slice(0, 5).toString('latin1') !== '%PDF-') return fehler(res, 415, 'File is not a valid PDF.'); try { const schema = await getSchema(); const filesSvc = new FilesService({ schema, accountability: null }); const id = await filesSvc.uploadOne(Readable.from(buf), { storage: STORAGE, filename_download: String(filename), title: String(filename), type: 'application/pdf', }); return res.json({ file_id: id }); } catch (err) { logger.error(`[skrift-api] files: ${err.stack || err.message}`); return fehler(res, 500, 'File upload failed.'); } }); // ── 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.'); } }); // ── Ö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])); // Eine Zeile je FTP-Sendung (Bündel), benannt wie die FTP-Datei (__). const versandSeite = ({ bundles = [], message = '', error = '' } = {}) => { const rows = bundles.length ? bundles.map((b) => `
${escH(b.name)}${Number(b.count) || 0} Empfänger · ${escH(String(b.date || '').slice(0, 10))}
`).join('') : '

Keine offenen Sendungen – alles versendet.

'; return ` Versand bestätigen

Versand bestätigen

Jede Zeile ist eine FTP-Sendung. Bitte „Versendet" klicken, sobald die Sendung verschickt ist.

${error ? `
${escH(error)}
` : ''} ${message ? `
${escH(message)}
` : ''} ${rows}
`; }; // Offene FTP-Sendungen: gebündelte Übermittlungen, noch nicht versendet, je bundle_name gruppiert. async function ladeBuendel(svc) { const rows = await svc('batch_submissions').readByQuery({ filter: { status: { _eq: 'bundled' }, versendet: { _neq: true }, bundle_name: { _nnull: true } }, limit: -1, sort: ['-bundle_name'], fields: ['id', 'bundle_name', 'date_created'], }); const m = new Map(); for (const r of rows || []) { if (!m.has(r.bundle_name)) m.set(r.bundle_name, { name: r.bundle_name, count: 0, date: r.date_created }); m.get(r.bundle_name).count += 1; } return [...m.values()]; } 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({ bundles: await ladeBuendel(svc) })); } catch (e) { logger.error(`[skrift-api] versand list: ${e.message}`); return res.type('html').send(versandSeite({ error: 'Sendungen 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 bundle = String((req.body && (req.body.bundle ?? req.body.name)) || req.query.bundle || '').trim(); try { const svc = svcFactory(await getSchema()); let message = '', error = ''; if (!bundle) { error = 'Bitte eine Sendung wählen.'; } else { const rows = await svc('batch_submissions').readByQuery({ filter: { bundle_name: { _eq: bundle }, status: { _eq: 'bundled' } }, limit: -1, fields: ['id'] }); const ids = (rows || []).map((r) => r.id); if (!ids.length) error = `Sendung ${bundle} nicht gefunden oder bereits versendet.`; else { await svc('batch_submissions').updateMany(ids, { versendet: true }); message = `Sendung ${bundle} als versendet markiert (${ids.length}).`; } } return res.type('html').send(versandSeite({ bundles: await ladeBuendel(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); if (!client) return fehler(res, 401, 'Missing or invalid API key.'); const body = req.body || {}; // Erlaubte Produkte des Keys: m2m-Liste + (Fallback) Einzelprodukt, dedupliziert. const erlaubt = {}; if (Array.isArray(client.products)) for (const j of client.products) if (j && j.product && j.product.id != null) erlaubt[j.product.id] = j.product; if (client.product && client.product.id != null) erlaubt[client.product.id] = client.product; const erlaubteListe = Object.values(erlaubt); if (!erlaubteListe.length) return fehler(res, 409, 'No product is assigned to this API key.'); // Produktwahl: body.product (Key) muss erlaubt sein; bei genau einem Produkt optional. const gewuenscht = String(body.product || '').trim(); let product; if (gewuenscht) { product = erlaubteListe.find((p) => String(p.key) === gewuenscht); if (!product) return fehler(res, 400, `product "${gewuenscht}" is not allowed for this key. Allowed: ${erlaubteListe.map((p) => p.key).join(', ')}.`); } else if (erlaubteListe.length === 1) { product = erlaubteListe[0]; } else { return fehler(res, 400, `Field "product" is required. Allowed: ${erlaubteListe.map((p) => p.key).join(', ')}.`); } const recipients = Array.isArray(body.recipients) ? body.recipients : []; if (!recipients.length) return fehler(res, 400, 'At least one recipient is required.'); if (recipients.length > 5000) return fehler(res, 400, 'Too many recipients (max. 5000).'); const istKuvertOnly = product.type === 'envelope'; const istDatei = product.input_mode === 'datei'; const letter = body.letter || {}; const files = Array.isArray(letter.files) ? letter.files.filter(Boolean) : []; // Kuvert-only: kein Brief nötig (nur adressierte Kuverts); PDF-Dateien optional. if (!istKuvertOnly) { if (istDatei && !files.length) return fehler(res, 400, 'This product requires uploaded print files (letter.files).'); if (!istDatei && !String(letter.text || '').trim()) return fehler(res, 400, 'letter.text is required.'); } try { const schema = await getSchema(); const svc = svcFactory(schema); const clean = await ladeClean(schema); // Schriftstückformat: nur A4 / A6H / A6L (freundliche Werte → interne Keys). const FORMAT_MAP = { a4: 'a4', a6h: 'a6_hoch', a6l: 'a6_quer', a6_hoch: 'a6_hoch', a6_quer: 'a6_quer' }; const fmtKey = FORMAT_MAP[String(body.document_format || '').trim().toLowerCase()] || null; if (!fmtKey && !istKuvertOnly) return fehler(res, 400, 'document_format must be a4, a6h or a6l.'); let formatId = null; if (fmtKey) { const fr = await svc('formats').readByQuery({ filter: { key: { _eq: fmtKey } }, limit: 1, fields: ['id'] }); formatId = fr?.[0]?.id ?? null; if (!formatId) return fehler(res, 400, `Format ${fmtKey} is not active.`); } // Kuvert: NUR "recipient" (Empfängeradresse) oder "none" (gar kein Kuvert). const envMode = String((body.envelope || {}).mode || '').toLowerCase() === 'recipient' ? 'recipient' : (istKuvertOnly ? 'recipient' : 'none'); // Kuvertformat automatisch aus dem Dokumentformat: A4 → DIN Lang, sonst C6. const envFormat = envMode === 'none' ? null : (fmtKey === 'a4' ? 'dinlang' : 'c6'); const hatDateien = files.length > 0; // ── Zeichen prüfen (WARNEN, nicht ablehnen) ────────────────────────────── // Nicht erlaubte Zeichen werden wie bisher entfernt; der Aufrufer bekommt // zusätzlich eine Warnung, welche Zeichen entfernt wurden. const unerlaubt = new Set(); const pruefeZeichen = (s) => { for (const ch of new Set(String(s || ''))) if (clean(ch) === '') unerlaubt.add(ch); }; if (!istDatei && !istKuvertOnly) pruefeZeichen(letter.text); for (const e of recipients) { for (const k of ['salutation', 'first_name', 'last_name', 'street', 'house_no', 'zip', 'city', 'country', 'free_text']) { pruefeZeichen(e && e[k]); } } 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.' }); } // ── Textlänge prüfen (ABLEHNEN) – NUR einseitige Formate (A6) ──────────── // A4 fließt automatisch über mehrere Seiten und kann nicht „zu lang" sein; // A6-Karten sind einseitig → passt der Text nicht auf eine Seite, wird der // Auftrag abgelehnt (die reine Umbruch-Zählung des Backends, kein Scriptalizer). if (!istDatei && !istKuvertOnly && (fmtKey === 'a6_hoch' || fmtKey === 'a6_quer')) { const layoutKey = fmtKey === 'a6_hoch' ? 'a6p' : 'a6l'; try { const r = await fetch(`${BACKEND_URL}/api/order/pagecount`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN }, body: JSON.stringify({ text: clean(String(letter.text || '')), format: layoutKey }), signal: AbortSignal.timeout(8000), }); if (r.ok) { const d = await r.json(); const pages = Math.max(1, ...((d.counts || []).map((c) => Number(c.pages) || 1))); if (pages > 1) { return fehler(res, 422, `letter.text is too long for a single ${String(body.document_format)} card (needs ${pages} pages). Please shorten it.`); } } else { logger.warn(`[skrift-api] Längenprüfung: pagecount HTTP ${r.status}`); } } catch (e) { // Backend nicht erreichbar → Auftrag NICHT blockieren (fail-open). logger.warn(`[skrift-api] Längenprüfung übersprungen: ${e.message}`); } } const font = ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda'; // Versandart nur Englisch: "single" (direkt an Empfänger) / "bulk" (Sammelversand an dich). const shippingType = String(body.shipping_type || '').toLowerCase() === 'single' ? 'einzeln' : 'sammel'; // Gewünschter Versandtag (optional), z. B. "montag". const shippingDay = WOCHENTAGE.includes(String(body.shipping_day || '').toLowerCase()) ? String(body.shipping_day).toLowerCase() : null; const orderData = { api_client: client.id, customer: client.customer || null, customer_email: (client.config && client.config.email) || null, product: product.id, format: formatId, person_type: 'unternehmen', font, realistic: body.realistic !== false, source: 'api', status: 'in_queue', production_status: 'eingegangen', payment_status: 'offen', payment_method: 'rechnung', shipping_type: shippingType, needs_envelope: envMode === 'recipient', envelope_labeling: envMode === 'recipient' ? 'empfaenger' : 'keine', envelope_text: null, envelope_format: envFormat, // Kein Brieftext bei Datei- oder Kuvert-only-Produkten. text_template: (istDatei || istKuvertOnly) ? null : clean(String(letter.text || '')), // Hochgeladene PDF-Briefe (Datei-Produkt ODER optional bei Kuvert-only). source_files: (istDatei || (istKuvertOnly && hatDateien)) ? files : null, source_file: (istDatei || (istKuvertOnly && hatDateien)) ? (files[0] || null) : null, entries_count: recipients.length, shipping_day: shippingDay, // Mehrseitigkeit läuft selbstständig (Auto-Fluss); nicht vom Kunden wählbar. multi_page: true, // Schriftgröße nicht vom Kunden wählbar. font_scale_letter: 1, font_scale_envelope: 1, }; // Auftragsnummer (Tages-Präfix) mit Kollisions-Wiederholung. const jt = new Date(); const praefix = `${String(jt.getDate()).padStart(2, '0')}-${String(jt.getMonth() + 1).padStart(2, '0')}-${String(jt.getFullYear()).slice(2)}`; const naechsteNummer = async () => { const heutige = await svc('orders').readByQuery({ filter: { order_number: { _starts_with: `${praefix}-` } }, limit: -1, fields: ['id'] }); return `${praefix}-${pad3((heutige?.length ?? 0) + 1)}`; }; let orderId, orderNumber; for (let v = 0; v < 6; v += 1) { orderNumber = await naechsteNummer(); try { orderId = await svc('orders').createOne({ order_number: orderNumber, ...orderData }); break; } catch (e) { if (v === 5 || !/unique|duplicate|bereits|exists/i.test(String(e?.message || ''))) throw e; } } // Empfängerzeilen – Briefnummer wird vom System vergeben. const entriesSvc = svc('order_entries'); let n = 0; for (const e of recipients) { n += 1; await entriesSvc.createOne({ order: orderId, letter_number: n, salutation: clean(e.salutation ?? null), first_name: clean(e.first_name ?? null), last_name: clean(e.last_name ?? null), street: clean(e.street ?? null), house_no: clean(e.house_no ?? null), zip: clean(e.zip ?? null), city: clean(e.city ?? null), country: clean(e.country ?? null), free_text: clean(e.free_text ?? null), // Keine Platzhalter über die API – der Brieftext wird 1:1 verwendet. placeholders: null, }); } await svc('status_history').createOne({ order: orderId, status: 'eingegangen', note: 'Über die Public-API angelegt.' }).catch(() => {}); // Generierung IMMER anstoßen (fire-and-forget) – für jedes Produkt. Das // Backend entscheidet, was zu erzeugen ist: Schriftstücke bei Handschrift- // Produkten, Kuverts immer wenn gewünscht; bei Datei-Produkten (z. B. // Unterschriftenservice) NUR die Kuverts. Ohne zu Erzeugendes = No-Op. fetch(`${BACKEND_URL}/api/order/from-directus`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN }, body: JSON.stringify({ orderId }), }).catch((e) => logger.warn(`[skrift-api] Generierung nicht gestartet: ${e.message}`)); return res.status(201).json({ order_number: orderNumber, status: 'pending', ...(warnings.length ? { warnings } : {}) }); } catch (err) { logger.error(`[skrift-api] orders: ${err.stack || err.message}`); return fehler(res, 500, 'Order could not be created.'); } }); // ── GET /v1/orders/:nummer ────────────────────────────────────────────────── router.get('/v1/orders/:nummer', async (req, res) => { const client = await authClient(req); if (!client) return fehler(res, 401, 'Missing or invalid API key.'); try { const schema = await getSchema(); const rows = await new ItemsService('orders', { schema, accountability: null }).readByQuery({ // Nur eigene Aufträge des Zugangs. filter: { order_number: { _eq: String(req.params.nummer) }, api_client: { _eq: client.id } }, limit: 1, fields: ['order_number', 'status', 'production_status', 'production_status_changed_at', 'versandt_am', 'entries_count', 'date_created'], }); const order = rows?.[0]; if (!order) return fehler(res, 404, 'Order not found.'); const status = kundenStatus(order); // Seit wann der Status so ist: Zeitpunkt der letzten Produktionsstatus- // Änderung; bei „shipped" ersatzweise der Versandzeitpunkt, sonst Anlage. const statusSince = order.production_status_changed_at || (status === 'shipped' ? order.versandt_am : null) || order.date_created || null; return res.json({ order_number: order.order_number, status, status_since: statusSince, recipients: order.entries_count ?? null, created_at: order.date_created ?? null, }); } catch (err) { logger.error(`[skrift-api] status: ${err.stack || err.message}`); return fehler(res, 500, 'Failed to retrieve order status.'); } }); // ── POST /v1/clients (Admin) – neuen Zugang + Key erzeugen ─────────────────── // Nur mit Directus-Admin-Token/Session. Der Klartext-Key wird EINMALIG geliefert. router.post('/v1/clients', async (req, res) => { if (!req.accountability || req.accountability.admin !== true) return fehler(res, 403, 'Administrators only.'); const { name, product, products, customer, webhook_url } = req.body || {}; // Mehrere Produkte (Array von IDs) bevorzugt; „product" (einzeln) als Kurzform. const prodIds = Array.isArray(products) ? products.filter((x) => x != null) : (product != null ? [product] : []); if (!name || !prodIds.length) return fehler(res, 400, 'name and products (array of product IDs) are required.'); try { const schema = await getSchema(); const key = `sk_live_${crypto.randomBytes(24).toString('base64url')}`; const id = await new ItemsService('api_clients', { schema, accountability: null }).createOne({ name: String(name), products: prodIds.map((pid) => ({ product: pid })), customer: customer || null, webhook_url: webhook_url || null, key_hash: sha256(key), key_prefix: key.slice(0, 14), active: true, }); // Klartext-Key nur JETZT – wird nirgends gespeichert. return res.status(201).json({ id, name, api_key: key, note: 'Store this key securely — it will not be shown again.' }); } catch (err) { logger.error(`[skrift-api] clients: ${err.stack || err.message}`); return fehler(res, 500, 'API client could not be created.'); } }); // ── OpenAPI + Doku ────────────────────────────────────────────────────────── const OPENAPI = { openapi: '3.0.3', info: { title: 'Skrift API', version: '1.0.0', description: 'Handwritten mail as a service. Authentication: API key in the X-Api-Key header. Bodies are JSON (UTF-8).' }, servers: [{ url: 'https://dev.skrift.de' }], components: { securitySchemes: { ApiKey: { type: 'apiKey', in: 'header', name: 'X-Api-Key' } }, schemas: { Recipient: { type: 'object', properties: { free_text: { type: 'string', description: 'Recipient address as free text, up to 5 lines separated by \\n.' }, salutation: { type: 'string' }, first_name: { type: 'string' }, last_name: { type: 'string' }, street: { type: 'string' }, house_no: { type: 'string' }, zip: { type: 'string' }, city: { type: 'string' }, country: { type: 'string' }, }, }, OrderRequest: { type: 'object', required: ['recipients'], properties: { product: { type: 'string', description: 'Product key to use. Required if the API key is bound to more than one product; optional (auto) if exactly one.' }, document_format: { type: 'string', enum: ['a4', 'a6h', 'a6l'], description: 'Document format.' }, font: { type: 'string', enum: ['tilda', 'alva', 'ellie'] }, realistic: { type: 'boolean', default: true, description: 'Natural handwriting variation.' }, shipping_type: { type: 'string', enum: ['single', 'bulk'], default: 'bulk', description: 'single = direct to each recipient; bulk = consolidated shipment to you.' }, shipping_day: { type: 'string', enum: ['montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonntag'], description: 'Preferred dispatch weekday (optional).' }, letter: { type: 'object', properties: { text: { type: 'string', description: 'Letter body, fully written out (no placeholders). Long text flows across multiple pages automatically; [[seitenumbruch]] forces a page break.' }, files: { type: 'array', items: { type: 'string' }, description: 'file_id(s) from POST /v1/files (file / PDF products).' }, }, }, envelope: { type: 'object', properties: { mode: { type: 'string', enum: ['none', 'recipient'], default: 'none', description: 'none = no envelope; recipient = recipient address on the envelope. Envelope size is selected automatically (A4 → DIN Lang, otherwise C6).' }, }, }, recipients: { type: 'array', items: { $ref: '#/components/schemas/Recipient' } }, }, }, }, }, security: [{ ApiKey: [] }], paths: { '/v1/orders': { post: { summary: 'Create an order', description: 'Quantity equals the number of recipients. Returns the order number.', requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/OrderRequest' } } } }, responses: { 201: { description: 'Order created', content: { 'application/json': { schema: { type: 'object', properties: { order_number: { type: 'string' }, status: { type: 'string' } } } } } } }, }, }, '/v1/orders/{order_number}': { get: { summary: 'Retrieve order status', parameters: [{ name: 'order_number', in: 'path', required: true, schema: { type: 'string' } }], responses: { 200: { description: 'Order status', content: { 'application/json': { schema: { type: 'object', properties: { order_number: { type: 'string' }, status: { type: 'string', enum: ['pending', 'processing', 'shipped', 'cancelled'] }, status_since: { type: 'string', format: 'date-time', description: 'ISO 8601 timestamp since which the order has held its current status.' }, recipients: { type: 'integer' } } } } } } }, }, }, '/v1/files': { post: { summary: 'Upload a PDF (PDF only, max. 80 MB)', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['filename', 'content_base64'], properties: { filename: { type: 'string', description: 'Must end in .pdf.' }, content_base64: { type: 'string', description: 'PDF as base64.' } } } } } }, 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' } } } } } } } }, }, }, }, }; router.get('/v1/openapi.json', (_req, res) => res.json(OPENAPI)); router.get('/docs', (_req, res) => { // Swagger UI, wenn die Assets im Extension-Ordner liegen; sonst die statische // HTML-Doku als Fallback (nie leer). Beides CSP-/Brave-sicher (kein CDN). let hasSwagger = false; try { hasSwagger = fs.existsSync(path.join(EXT_DIR, 'swagger-ui-bundle.js')); } catch { /* ignore */ } res.type('html').send(hasSwagger ? SWAGGER_HTML : DOCS_HTML); }); // Statische Doku-Assets (Swagger UI) – von der eigenen Domain, whitelisted. router.get('/docs-assets/:file', (req, res) => { const name = String(req.params.file || ''); if (name === 'init.js') { res.type('application/javascript').send(INIT_JS); return; } const TYPES = { 'swagger-ui.css': 'text/css', 'swagger-ui-bundle.js': 'application/javascript' }; if (!TYPES[name]) return res.status(404).json({ error: 'Not found.' }); try { const buf = fs.readFileSync(path.join(EXT_DIR, name)); res.setHeader('Content-Type', TYPES[name]); res.setHeader('Cache-Control', 'public, max-age=86400'); return res.send(buf); } catch { return res.status(404).json({ error: 'Docs asset missing – place swagger-ui.css and swagger-ui-bundle.js in the extension folder.' }); } }); }; // Objekt-Form mit fester id → Mount unter /skrift-api (wie skrift-orders). export default { id: 'skrift-api', handler };