- /v1/batch nun in der HTML-Doku und in der OpenAPI-Spezifikation. - Neue oeffentliche Seite an EINER kryptischen URL /v1/versand/<SKRIFT_VERSAND_TOKEN> (ohne Login): zeigt ALLE offenen Auftraege (Nummer, Status, Menge, Datum) in einer Uebersicht; pro Auftrag ein "Versendet"-Knopf setzt production_status 'versendet' + versandt_am (Status-API liefert dann 'shipped', Versandbenachrichtigung geht raus). Schutz = geheimer Pfad-Token (ENV); ohne Token 404. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
810 lines
47 KiB
JavaScript
810 lines
47 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 || '';
|
||
// Geheimer (kryptischer) Pfad-Token für die öffentliche Versand-Bestätigungsseite.
|
||
// Ohne gesetzten Wert ist die Seite aus (404). URL: /v1/versand/<SKRIFT_VERSAND_TOKEN>
|
||
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 = `<!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>
|
||
|
||
<h2>Batch submission <span class="muted">· alternative delivery</span></h2>
|
||
<div class="card">
|
||
<div class="ep"><span class="m post">POST</span> /v1/batch</div>
|
||
<p>Alternative to <code>/v1/orders</code> for a lettershop workflow: submit <b>one recipient per call</b> in a fixed schema, plus <b>either a ready print PDF</b> (used as-is) <b>or</b> letter <code>text</code>. Submissions are collected and bundled <b>per API key, once a day</b> (03:00 the next morning; Sat+Sun together) into one <code>.xlsx</code> (the schema below) and one merged print <code>.pdf</code>, then uploaded to the configured FTP target.</p>
|
||
<h3>Request body</h3>
|
||
<table>
|
||
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
|
||
<tr><td>firma</td><td>string</td><td>Company (optional).</td></tr>
|
||
<tr><td>vorname</td><td>string</td><td>First name.</td></tr>
|
||
<tr><td>nachname</td><td>string</td><td>Last name.</td></tr>
|
||
<tr><td>zeile1, zeile2, zeile3</td><td>string</td><td>Free address lines.</td></tr>
|
||
<tr><td>plz</td><td>string</td><td>Postal code.</td></tr>
|
||
<tr><td>stadt</td><td>string</td><td>City.</td></tr>
|
||
<tr><td>land</td><td>string</td><td>Country.</td></tr>
|
||
<tr><td>pdf_file_id</td><td>string</td><td>A <code>file_id</code> from <code>POST /v1/files</code> — the ready print PDF (used 1:1). <b>Provide this or</b> <code>pdf_base64</code> <b>or</b> <code>text</code>.</td></tr>
|
||
<tr><td>pdf_base64</td><td>string</td><td>Alternatively the PDF inline as base64.</td></tr>
|
||
<tr><td>text</td><td>string</td><td>Alternatively a letter text (handwriting); not part of the FTP bundle.</td></tr>
|
||
</table>
|
||
<p class="muted">At least one address field is required, and exactly one of <code>pdf_file_id</code> / <code>pdf_base64</code> / <code>text</code>.</p>
|
||
<pre>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" }</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/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/<SKRIFT_VERSAND_TOKEN> : Auftragsnummer eingeben → Auftrag wird als
|
||
// „versendet" markiert (Status-API liefert dann „shipped"). Schutz = geheimer
|
||
// Pfad-Token (kein Konto). Ohne gesetzten Token ist die Seite aus.
|
||
const escH = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||
const VS_LABEL = { eingegangen: 'Eingegangen', im_druck: 'Im Druck', kuvertieren: 'Kuvertierung', versandfertig: 'Versandfertig' };
|
||
const versandSeite = ({ orders = [], message = '', error = '' } = {}) => {
|
||
const rows = orders.length ? orders.map((o) => `
|
||
<div class="row">
|
||
<div class="info"><b>${escH(o.order_number)}</b><span>${escH(VS_LABEL[o.production_status] || o.production_status || 'offen')} · ${Number(o.entries_count) || 1} Empf. · ${escH(String(o.date_created || '').slice(0, 10))}</span></div>
|
||
<form method="post"><input type="hidden" name="order_number" value="${escH(o.order_number)}"><button type="submit">Versendet ✓</button></form>
|
||
</div>`).join('') : '<p class="muted">Keine offenen Aufträge – alles versendet.</p>';
|
||
return `<!doctype html><html lang="de"><head>
|
||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Versand bestätigen</title>
|
||
<style>
|
||
body{font-family:system-ui,Segoe UI,Arial,sans-serif;background:#f1f4f9;margin:0;padding:20px;color:#1a2a3a}
|
||
.card{max-width:620px;margin:5vh auto;background:#fff;border:1px solid #cdd6e2;border-radius:14px;padding:24px;box-shadow:0 4px 20px rgba(0,0,0,.06)}
|
||
h1{font-size:20px;margin:0 0 4px} p{font-size:14px;line-height:1.5;color:#33475b}
|
||
.muted{color:#8a97a8}
|
||
.ok{background:#e7f6ec;border:1px solid #34a853;color:#1e7e34;border-radius:10px;padding:12px 14px;margin:0 0 10px}
|
||
.err{background:#fdeaea;border:1px solid #c62828;color:#b3261e;border-radius:10px;padding:12px 14px;margin:0 0 10px}
|
||
.row{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:12px 14px;border:1px solid #e2e8f0;border-radius:10px;margin-top:8px}
|
||
.info{display:flex;flex-direction:column;min-width:0} .info b{font-size:16px} .info span{font-size:12px;color:#8a97a8}
|
||
form{margin:0} button{font-size:14px;font-weight:700;padding:10px 16px;border:none;border-radius:9px;background:#1E50A5;color:#fff;cursor:pointer;white-space:nowrap}
|
||
</style></head><body><div class="card">
|
||
<h1>Versand bestätigen</h1>
|
||
<p>Alle offenen Aufträge. Bitte pro Auftrag „Versendet" klicken, sobald er verschickt ist.</p>
|
||
${error ? `<div class="err">${escH(error)}</div>` : ''}
|
||
${message ? `<div class="ok">${escH(message)}</div>` : ''}
|
||
${rows}
|
||
</div></body></html>`;
|
||
};
|
||
|
||
// Offene Aufträge (noch nicht versendet/storniert) – nur Nummer/Status/Menge/Datum.
|
||
async function ladeOffeneAuftraege(svc) {
|
||
return svc('orders').readByQuery({
|
||
filter: { production_status: { _nin: ['versendet', 'storniert'] }, status: { _neq: 'entwurf' } },
|
||
sort: ['-date_created'], limit: 500,
|
||
fields: ['id', 'order_number', 'production_status', 'entries_count', 'date_created'],
|
||
});
|
||
}
|
||
|
||
router.get('/v1/versand/:token', async (req, res) => {
|
||
if (!VERSAND_TOKEN || req.params.token !== VERSAND_TOKEN) return res.status(404).type('html').send('Not found.');
|
||
try {
|
||
const svc = svcFactory(await getSchema());
|
||
return res.type('html').send(versandSeite({ orders: (await ladeOffeneAuftraege(svc)) || [] }));
|
||
} catch (e) {
|
||
logger.error(`[skrift-api] versand list: ${e.message}`);
|
||
return res.type('html').send(versandSeite({ error: 'Aufträge konnten nicht geladen werden.' }));
|
||
}
|
||
});
|
||
|
||
router.post('/v1/versand/:token', async (req, res) => {
|
||
if (!VERSAND_TOKEN || req.params.token !== VERSAND_TOKEN) return res.status(404).type('html').send('Not found.');
|
||
const nr = String((req.body && (req.body.order_number ?? req.body.nummer)) || req.query.order_number || '').trim();
|
||
try {
|
||
const svc = svcFactory(await getSchema());
|
||
let message = '', error = '';
|
||
if (!nr) {
|
||
error = 'Bitte einen Auftrag wählen.';
|
||
} else {
|
||
const rows = await svc('orders').readByQuery({ filter: { order_number: { _eq: nr } }, limit: 1, fields: ['id', 'order_number', 'production_status'] });
|
||
const order = rows?.[0];
|
||
if (!order) error = `Auftrag ${nr} wurde nicht gefunden.`;
|
||
else if (order.production_status === 'versendet') message = `Auftrag ${nr} war bereits als versendet markiert.`;
|
||
else if (order.production_status === 'storniert') error = `Auftrag ${nr} ist storniert.`;
|
||
else { await svc('orders').updateOne(order.id, { production_status: 'versendet', versandt_am: new Date().toISOString() }); message = `Auftrag ${nr} als versendet markiert.`; }
|
||
}
|
||
return res.type('html').send(versandSeite({ orders: (await ladeOffeneAuftraege(svc)) || [], message, error }));
|
||
} catch (e) {
|
||
logger.error(`[skrift-api] versand: ${e.stack || e.message}`);
|
||
return res.type('html').send(versandSeite({ error: 'Konnte nicht gespeichert werden. Bitte erneut versuchen.' }));
|
||
}
|
||
});
|
||
|
||
// ── POST /v1/orders ─────────────────────────────────────────────────────────
|
||
router.post('/v1/orders', async (req, res) => {
|
||
const client = await authClient(req);
|
||
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 };
|