573 lines
32 KiB
JavaScript
573 lines
32 KiB
JavaScript
/**
|
||
* 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: <API_KEY></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 > 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: <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"}
|
||
]
|
||
}'</pre>
|
||
<h3>Example — print uploaded PDFs (file product)</h3>
|
||
<pre>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"}]}'</pre>
|
||
<h3>Example — address envelopes only</h3>
|
||
<pre>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"}]}'</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: <KEY>"
|
||
|
||
{ "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: <KEY>" -H "Content-Type: application/json" \\
|
||
-d '{"filename":"letter.pdf","content_base64":"<BASE64>"}'
|
||
|
||
{ "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 };
|