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:
@@ -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: <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>`;
|
||||
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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' } } } } } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user