Files
skrift-programme/Docker/directus/extensions/directus-extension-skrift-api/dist/index.js
Lucas Orth 57d48409e6 Kontakt-CRM, Chargen-Sammeldruck, API-Status & Auto-Generierung
Kontakte/CRM (Directus):
- Neue Collections `contacts` (Leads) + `contact_actions` (Rückruf/Angebot/Mail).
- Hook `skrift-contacts`: Kontakte werden nur auf Anforderung angelegt
  (Auftrag "In Kontakte speichern"); Rückruf-Erinnerung (Zeitplan) und
  Angebots-/Mail-an-mich-Mails; Auto-Löschung nur bei Lead-Status
  "kein_interesse" nach 30 Tagen; optionaler DSGVO-Strip (per Env, Dry-Run).
- Modul `skrift-kontakte`: handytaugliches Board (Anruf via groundwire:,
  Notizen mit Autosave, Durchblättern, CSV-Export, Aktionen als Buttons).

API-Chargen & PDF-Sammeldruck:
- Produktions-Board gruppiert API-Aufträge unter "Neu" zu Chargen
  (Kunde + Typ + Versandtag + Eingangstag), aufklappbar, mit "fällig bis"
  (Versandtag der übernächsten Woche).
- Mehrfachauswahl -> Sammel-PDF (Backend `pdf-lib`, /api/charge/merge-pdf,
  Directus-Proxy /skrift-orders/charge/pdf), Kuverts an Plotter und
  Sammel-Status; PDF- und Kuvert-Reihenfolge nach Auftragsnummer (Kollation).
- Mailer: keine Einzelmail je API-Auftrag mehr, stattdessen Tagesübersicht
  (18:00) pro Kunde, nach Versandtag aufgeschlüsselt.

Status-API & Generierung:
- Public-API-Status liest jetzt production_status (Board) statt nur
  kaufmännischem Status; neues Feld `status_since` in der Antwort.
- Auftrag stempelt `production_status_changed_at` bei jedem Statuswechsel.
- Generierung wird IMMER beim Eingang angestoßen (auch Datei-Produkte mit
  handgeschriebenem Kuvert), nicht nur bei letter/postcard.
- Versandtag im Produktionsdashboard sichtbar.

Rechte: Service-Rolle darf directus_files lesen (Backend lädt PDFs für den
Sammeldruck über /assets).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 08:53:08 +02:00

573 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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: <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 || '';
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 = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Skrift API Reference</title>
<style>
*{box-sizing:border-box}
body{margin:0;font:16px/1.6 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a;background:#f6f8fb}
.wrap{max-width:900px;margin:0 auto;padding:32px 20px 80px}
h1{font-size:30px;margin:0 0 4px}
h2{font-size:22px;margin:40px 0 12px;padding-top:14px;border-top:1px solid #e2e8f0}
h3{font-size:15px;margin:18px 0 6px}
code{background:#eef2f7;padding:2px 6px;border-radius:5px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:14px}
pre{background:#0f172a;color:#e6edf3;padding:16px;border-radius:10px;overflow:auto;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:13px;line-height:1.5}
.card{background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:18px 20px;margin:14px 0}
.ep{display:flex;align-items:center;gap:10px;font-family:ui-monospace,monospace;font-size:15px;margin-bottom:6px}
.m{font-weight:700;color:#fff;border-radius:6px;padding:2px 8px;font-size:12px}
.post{background:#16a34a}.get{background:#2563eb}
table{border-collapse:collapse;width:100%;margin:8px 0;font-size:14px}
th,td{text-align:left;border-bottom:1px solid #e2e8f0;padding:7px 8px;vertical-align:top}
th{color:#64748b;font-weight:600}
.muted{color:#64748b}
.tag{display:inline-block;background:#eef2f7;border-radius:6px;padding:1px 8px;font-size:13px;margin:0 6px 4px 0}
a{color:#1E50A5}
</style></head>
<body><div class="wrap">
<h1>Skrift API Reference</h1>
<p class="muted">Handwritten mail as a service · API v1 · Base URL <code>https://dev.skrift.de</code></p>
<h2>Overview</h2>
<div class="card">
<p>REST API over HTTPS. Request and response bodies are JSON (<code>Content-Type: application/json</code>, UTF-8). An order yields one document and/or one envelope per recipient; the <b>quantity is derived from the number of recipients</b>. One or more products are bound to your API key; each order selects one via the <code>product</code> field.</p>
</div>
<h2>Authentication</h2>
<div class="card">
<p>Every request must include your API key in the <code>X-Api-Key</code> header:</p>
<pre>X-Api-Key: &lt;API_KEY&gt;</pre>
<p class="muted">Each key is scoped to exactly one product. Keys are secrets — never expose them in client-side code.</p>
</div>
<h2>Errors</h2>
<div class="card">
<p>Errors use standard HTTP status codes and a uniform body:</p>
<pre>{ "error": "human-readable message" }</pre>
<table>
<tr><th>Status</th><th>Meaning</th></tr>
<tr><td>200 / 201</td><td>Success / order created</td></tr>
<tr><td>400</td><td>Invalid request (missing or invalid fields)</td></tr>
<tr><td>401</td><td>Missing or invalid API key</td></tr>
<tr><td>404</td><td>Resource not found</td></tr>
<tr><td>413</td><td>Payload too large (file &gt; 80 MB)</td></tr>
<tr><td>415</td><td>Unsupported media type (only PDF is accepted)</td></tr>
<tr><td>500</td><td>Internal error</td></tr>
</table>
</div>
<h2>Order status</h2>
<div class="card">
<p>The <code>status</code> field returns one of:</p>
<span class="tag">pending</span> received, not yet in production
<span class="tag">processing</span> being written / printed
<span class="tag">shipped</span> dispatched
<span class="tag">cancelled</span> cancelled
</div>
<h2>Create an order</h2>
<div class="card">
<div class="ep"><span class="m post">POST</span> /v1/orders</div>
<p>Creates an order and returns its order number.</p>
<h3>Request body</h3>
<table>
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>product</td><td>string</td><td>Product key to use. Required if your key allows multiple products; optional (auto) if exactly one.</td></tr>
<tr><td>recipients</td><td>array</td><td><b>Required.</b> One entry per recipient; count = quantity. Address as <code>free_text</code> (up to 5 lines, separated by <code>\\n</code>).</td></tr>
<tr><td>document_format</td><td>string</td><td>Document format: <code>a4</code>, <code>a6h</code> or <code>a6l</code>.</td></tr>
<tr><td>font</td><td>string</td><td><code>tilda</code>, <code>alva</code> or <code>ellie</code>.</td></tr>
<tr><td>realistic</td><td>boolean</td><td>Natural handwriting variation. Default <code>true</code>.</td></tr>
<tr><td>shipping_type</td><td>string</td><td><code>bulk</code> (consolidated shipment to you; default) or <code>single</code> (direct to each recipient).</td></tr>
<tr><td>shipping_day</td><td>string</td><td>Preferred dispatch weekday, e.g. <code>montag</code> (optional).</td></tr>
<tr><td>letter.text</td><td>string</td><td>Letter body, <b>fully written out — no placeholders</b>. Long text flows across multiple pages automatically; <code>[[seitenumbruch]]</code> forces a page break.</td></tr>
<tr><td>letter.files</td><td>array</td><td><code>file_id</code>(s) from <code>POST /v1/files</code> (file / PDF products).</td></tr>
<tr><td>envelope.mode</td><td>string</td><td><code>none</code> (no envelope) or <code>recipient</code> (recipient address on the envelope). Envelope size is selected automatically: A4 → DIN Lang, otherwise C6.</td></tr>
</table>
<h3>Example — text letter</h3>
<pre>curl -X POST https://dev.skrift.de/v1/orders \\
-H "X-Api-Key: &lt;KEY&gt;" -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"}
]
}'</pre>
<h3>Example — print uploaded PDFs (file product)</h3>
<pre>curl -X POST https://dev.skrift.de/v1/orders \\
-H "X-Api-Key: &lt;KEY&gt;" -H "Content-Type: application/json" \\
-d '{"document_format":"a4","letter":{"files":["&lt;file_id&gt;"]},
"envelope":{"mode":"recipient"},
"recipients":[{"free_text":"Anna Meier\\nMusterstr. 1\\n12345 Musterstadt"}]}'</pre>
<h3>Example — address envelopes only</h3>
<pre>curl -X POST https://dev.skrift.de/v1/orders \\
-H "X-Api-Key: &lt;KEY&gt;" -H "Content-Type: application/json" \\
-d '{"envelope":{"mode":"recipient"},
"recipients":[{"free_text":"Anna Meier\\nMusterstr. 1\\n12345 Musterstadt"}]}'</pre>
<h3>Response <span class="muted">· 201</span></h3>
<pre>{ "order_number": "18-08-26-001", "status": "pending" }</pre>
</div>
<h2>Retrieve order status</h2>
<div class="card">
<div class="ep"><span class="m get">GET</span> /v1/orders/{order_number}</div>
<p>Returns the current status of one of your orders. <code>status_since</code> is the ISO 8601 timestamp (UTC) since which the order has been in its current status.</p>
<pre>curl https://dev.skrift.de/v1/orders/18-08-26-001 -H "X-Api-Key: &lt;KEY&gt;"
{ "order_number":"18-08-26-001", "status":"shipped", "status_since":"2026-08-20T09:14:00.000Z", "recipients":1 }</pre>
</div>
<h2>Upload a PDF</h2>
<div class="card">
<div class="ep"><span class="m post">POST</span> /v1/files</div>
<p>Uploads a base64-encoded print file. <b>PDF only</b>, max. 80 MB. Returns a <code>file_id</code> to reference in <code>letter.files</code>.</p>
<pre>curl -X POST https://dev.skrift.de/v1/files \\
-H "X-Api-Key: &lt;KEY&gt;" -H "Content-Type: application/json" \\
-d '{"filename":"letter.pdf","content_base64":"&lt;BASE64&gt;"}'
{ "file_id": "..." }</pre>
</div>
<p class="muted" style="margin-top:30px">Machine-readable specification: <a href="v1/openapi.json">OpenAPI (JSON)</a> — import into Postman, Insomnia, or any OpenAPI client.</p>
</div></body></html>`;
// 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 = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Skrift API Reference</title>
<link rel="stylesheet" href="docs-assets/swagger-ui.css">
<style>body{margin:0;background:#fafafa}.swagger-ui .topbar{display:none}</style>
</head><body>
<div id="swagger-ui"></div>
<script src="docs-assets/swagger-ui-bundle.js"></script>
<script src="docs-assets/init.js"></script>
</body></html>`;
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/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;
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' });
} 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' } } } } } } },
},
},
},
};
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 };