API: /v1/batch dokumentiert + oeffentliche Versand-Uebersicht

- /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>
This commit is contained in:
s4luorth
2026-09-28 09:34:15 +02:00
parent 8ad087d007
commit 2aa6f008bf

View File

@@ -27,6 +27,9 @@ const EXT_DIR = path.dirname(fileURLToPath(import.meta.url));
const BACKEND_URL = (process.env.SKRIFT_BACKEND_URL || 'http://skrift-backend:4000').replace(/\/$/, '');
const BACKEND_TOKEN = process.env.SKRIFT_BACKEND_TOKEN || '';
// Geheimer (kryptischer) Pfad-Token für die öffentliche Versand-Bestätigungsseite.
// Ohne gesetzten Wert ist die Seite aus (404). URL: /v1/versand/<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');
@@ -177,6 +180,34 @@ const DOCS_HTML = `<!doctype html>
{ "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: &lt;KEY&gt;" -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":"&lt;file_id&gt;"}'
{ "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>`;
@@ -332,6 +363,82 @@ const handler = (router, { services, getSchema, logger }) => {
}
});
// ── Ö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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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);
@@ -652,6 +759,22 @@ const handler = (router, { services, getSchema, logger }) => {
responses: { 200: { description: 'Uploaded', content: { 'application/json': { schema: { type: 'object', properties: { file_id: { type: 'string' } } } } } } },
},
},
'/v1/batch': {
post: {
summary: 'Batch submission (alternative delivery)',
description: 'One recipient per call in a fixed schema plus either a ready print PDF (pdf_file_id/pdf_base64) or letter text. Bundled per API key once a day into an .xlsx + merged print .pdf and uploaded to FTP.',
requestBody: { required: true, content: { 'application/json': { schema: { type: 'object',
properties: {
firma: { type: 'string' }, vorname: { type: 'string' }, nachname: { type: 'string' },
zeile1: { type: 'string' }, zeile2: { type: 'string' }, zeile3: { type: 'string' },
plz: { type: 'string' }, stadt: { type: 'string' }, land: { type: 'string' },
pdf_file_id: { type: 'string', description: 'file_id from POST /v1/files (ready print PDF, used 1:1).' },
pdf_base64: { type: 'string', description: 'Alternatively the PDF inline as base64.' },
text: { type: 'string', description: 'Alternatively a letter text (handwriting); not part of the FTP bundle.' },
} } } } },
responses: { 201: { description: 'Accepted', content: { 'application/json': { schema: { type: 'object', properties: { id: { type: 'string' }, status: { type: 'string' }, warnings: { type: 'array', items: { type: 'object' } } } } } } } },
},
},
},
};