Files
skrift-programme/Docker/skrift-backend/src/api/controllers/directus-controller.js
Lucas Orth 1d5c1a14d4 Public-API, mehrseitige Schriftstücke, manuelle Aufträge & Mailer
Public-API (neu, directus-extension-skrift-api):
- Versionierter /v1-Namespace: POST /v1/orders, GET /v1/orders/:nr (Status
  pending/processing/shipped/cancelled), POST /v1/files (nur PDF, ≤80 MB).
- Auth über X-Api-Key (Authorization ist von Directus reserviert), SHA-256-Hash.
- Produkt-gebunden; Kuvert-only-Produkttyp; Kuvertformat automatisch (A4→DIN Lang, sonst C6);
  kein Platzhalter, kein Kunden-multipage/font_scale; Versandart englisch (single/bulk).
- Selbst gehostete Swagger-UI-Doku (/docs) + OpenAPI (/v1/openapi.json), englisch/technisch.
- Key-Hook (directus-extension-skrift-apikey): erzeugt Key automatisch, zeigt ihn
  einmalig in key_plain.

Mehrseitige Schriftstücke:
- generateLetterPages (Auto-Fluss, [[seitenumbruch]], Leerseiten-Filter).
- Preview/Order-Generierung schreiben letter_NNN_pM.svg; Seiten-Übersicht im Neuauftrag.
- Agent (skrift-agent): Seiten je Brief absteigend an den Plotter (Stapel-Reihenfolge).
- countLetterPages + /api/order/pagecount für die Seiten-Vorschau.

Manuelle Aufträge:
- Schriftgröße (Brief/Kuvert), Signatur auf dem Brief mit X/Y/Größe-Feinjustierung,
  realistische Handschrift bis in den finalen Druck durchgereicht.
- Spalten-Einfügen in der Empfängertabelle (Excel-Spalte je Zelle).

Mailer: Text-Alternative + Reply-To (Zustellbarkeit).

App: Freitext in Musteranfrage, Konfigurator-Reset, Lightbox-Seitenverhältnis +
Bild-Download, Sticky-Vorschau-Fix, Tab-Eingabe + Rechtsbündigkeit, Leerzeilen-Trim.

Bootstrap: neue Felder (font_scale_*, signatures_manual, multi_page, page_count,
realistic, shipping_day, api_client) + Collections api_clients, Produkttyp envelope.

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

226 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Generierung auf Basis eines Directus-Auftrags.
*
* Ablauf: Directus meldet "Auftrag X ist bezahlt" → dieser Endpunkt holt den
* Auftrag samt Empfängerzeilen aus Directus, baut daraus die Dokumentliste für
* die bestehende Pipeline (runGeneration) und schreibt Ergebnis + Status zurück.
*
* Antwortet sofort mit 202 – die Generierung läuft im Hintergrund weiter, weil
* sie je nach Menge deutlich länger dauern kann als ein HTTP-Timeout erlaubt.
*/
const { runGeneration } = require('./order-controller');
const directus = require('../../services/directus-service');
const { countLetterPages } = require('../../lib/svg-generator');
const { replacePlaceholders } = require('../../services/placeholder-service');
// Directus-Formatschlüssel → Backend-Formate
const LETTER_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
const ENVELOPE_FORMAT = { c6: 'c6', dinlang: 'din_lang' };
/** Baut den Adressblock für einen Umschlag aus einer Empfängerzeile. */
function addressText(entry) {
if (entry.free_text) return String(entry.free_text).trim();
const name = [entry.first_name, entry.last_name].filter(Boolean).join(' ');
const street = [entry.street, entry.house_no].filter(Boolean).join(' ');
const city = [entry.zip, entry.city].filter(Boolean).join(' ');
const isForeign = entry.country && !/^(de|deutschland|germany)$/i.test(String(entry.country).trim());
return [name, street, city, isForeign ? entry.country : null]
.filter(Boolean).join('\n');
}
/** Platzhalterwerte einer Zeile (strukturierte Felder + freie Platzhalter). */
function placeholdersOf(entry) {
return {
Anrede: entry.salutation || '',
Vorname: entry.first_name || '',
Nachname: entry.last_name || '',
Strasse: [entry.street, entry.house_no].filter(Boolean).join(' '),
PLZ: entry.zip || '',
Ort: entry.city || '',
Land: entry.country || '',
Briefnummer: entry.letter_number ?? '',
...(entry.placeholders && typeof entry.placeholders === 'object' ? entry.placeholders : {}),
};
}
/** Übersetzt einen Directus-Auftrag in die Dokumentliste für runGeneration. */
function buildDocuments(order, entries) {
const font = order.font || 'tilda';
// Eigene Format-Schlüssel (freie Formate) unverändert durchreichen.
const letterFormat = LETTER_FORMAT[order.format?.key] || order.format?.key || 'a4';
const envFormat = ENVELOPE_FORMAT[order.envelope_format] || 'c6';
const wantsEnvelope = !!order.needs_envelope && order.envelope_labeling !== 'keine';
const envelopeType = order.envelope_labeling === 'freitext' ? 'custom' : 'recipient';
// Datei-Produkte (z. B. Unterschriftenservice): das „Schriftstück" ist das vom
// Kunden hochgeladene Dokument – es wird NICHT generiert. Kuverts hingegen schon.
const istDatei = order.product?.input_mode === 'datei';
// Kuvert-only-Produkt: es wird KEIN Schriftstück generiert, nur die (adressierten)
// Kuverts. Etwaige hochgeladene PDF-Briefe liegen als source_files am Auftrag.
const istKuvertOnly = order.product?.type === 'envelope';
// Natürliche Handschrift-Variation (Default an), am Auftrag gespeichert.
const realistisch = order.realistic !== false;
// Relative Schriftgröße (getrennt Brief/Kuvert), aus dem Auftrag. 1 = Standard.
const fontScaleLetter = Number(order.font_scale_letter) || 1;
const fontScaleEnv = Number(order.font_scale_envelope) || 1;
// Signatur(en) auf dem Brief (manuelle Aufträge): SVG je [[signaturN]] + Feinjustierung.
const sigManual = order.signatures_manual && typeof order.signatures_manual === 'object' ? order.signatures_manual : null;
const sigMap = sigManual?.signatures || {};
const sigOff = sigManual?.offsets || {};
// Mehrseitiges Schriftstück (Auto-Fluss über N Seiten) – nur wenn am Auftrag gesetzt.
const istMehrseitig = !!order.multi_page;
const documents = [];
entries.forEach((entry, i) => {
const placeholders = placeholdersOf(entry);
// Dateinummer = Briefnummer (1-basiert), damit letter_001.svg zu
// Briefnummer 1 gehört. Vorschau nummeriert identisch (siehe skrift-orders).
const nr = Number(entry.letter_number) || (i + 1);
if (!istDatei && !istKuvertOnly) {
// Abweichender Text je Schriftstück (#5) hat Vorrang; sonst Standardtext.
const briefText = (entry.text && String(entry.text).trim()) ? entry.text : (order.text_template || '');
documents.push({
index: nr,
type: 'letter',
text: briefText,
format: letterFormat,
font,
placeholders,
fontScale: fontScaleLetter,
signatures: sigMap,
signatureOffsets: sigOff,
multiPage: istMehrseitig,
realisticHandwriting: realistisch,
});
}
if (wantsEnvelope) {
documents.push({
index: nr,
type: 'envelope',
envelopeType,
text: envelopeType === 'recipient' ? addressText(entry) : (order.envelope_text || order.text_template || ''),
format: envFormat,
font,
placeholders,
fontScale: fontScaleEnv,
realisticHandwriting: realistisch,
});
}
});
return { documents, letterFormat, envFormat, wantsEnvelope };
}
/**
* POST /api/order/from-directus
* Body: { orderId }
*/
async function generateFromDirectus(req, res, next) {
const { orderId } = req.body || {};
if (!orderId) {
return res.status(400).json({ error: 'Invalid request', message: 'orderId is required' });
}
let order;
try {
const loaded = await directus.fetchOrder(orderId);
order = loaded.order;
if (!loaded.entries.length) {
return res.status(400).json({
error: 'Invalid order',
message: `Auftrag ${order.order_number} hat keine Empfängerzeilen.`
});
}
// Sofort quittieren – der Rest läuft im Hintergrund.
res.status(202).json({
accepted: true,
orderId,
orderNumber: order.order_number,
documents: loaded.entries.length * (order.needs_envelope ? 2 : 1),
});
const { documents, letterFormat, envFormat, wantsEnvelope } = buildDocuments(order, loaded.entries);
// Nichts zu generieren (z. B. Datei-Produkt ohne Kuvert) → sauber überspringen.
if (!documents.length) {
console.log(`[Directus] ${order.order_number}: nichts zu generieren (Datei-Produkt ohne Kuvert).`);
return;
}
console.log(`[Directus] Generiere ${order.order_number}: ${documents.length} Dokumente`);
const result = await runGeneration({
orderNumber: order.order_number,
letters: documents,
metadata: {
letterFormat,
envelopeFormat: wantsEnvelope ? envFormat : null,
envelopeBeschriftung: wantsEnvelope ? order.envelope_labeling : null,
directusOrderId: orderId,
customerEmail: order.customer_email,
// Vorschau-Session → gezeigte Dokumente 1:1 übernehmen.
previewSession: order.preview_session || null,
// Eingegebener Brieftext → als brieftext.txt im Ausgabeordner ablegen.
rawText: order.text_template || null,
},
});
await directus.updateOrder(orderId, {
status: 'in_queue',
artifact_path: result.outputPath,
});
await directus.addStatusHistory(orderId, 'in_queue',
`${result.letters.length} Schriftstücke, ${result.envelopes.length} Umschläge generiert.`);
console.log(`[Directus] ${order.order_number} fertig → in_queue`);
} catch (err) {
console.error('[Directus] Generierung fehlgeschlagen:', err.message);
// Fehler in Directus sichtbar machen, statt still zu scheitern.
if (order?.id || orderId) {
try {
await directus.addStatusHistory(orderId, 'fehler', `Generierung fehlgeschlagen: ${err.message}`);
} catch (e) {
console.error('[Directus] Konnte Fehler nicht zurückschreiben:', e.message);
}
}
// Antwort wurde ggf. schon gesendet (202) – dann nur noch loggen.
if (!res.headersSent) {
if (err.status) return res.status(err.status).json({ error: 'Generation failed', message: err.message });
return next(err);
}
}
}
/**
* POST /api/order/pagecount
* Body: { text, format, fontScale, entries: [{ placeholders }] }
* Liefert je Dokument die Seitenzahl (nur Umbruch-Zählung, kein Scriptalizer/SVG).
*/
function pageCounts(req, res) {
const { text, format, fontScale, entries } = req.body || {};
if (!text || !String(text).trim()) {
return res.status(400).json({ error: 'Invalid request', message: 'text is required' });
}
const fmt = format || 'a4';
const scale = Number(fontScale) || 1;
const list = Array.isArray(entries) && entries.length ? entries : [{}];
const counts = list.map((e, i) => {
const ph = { ...(e && typeof e.placeholders === 'object' ? e.placeholders : {}), Briefnummer: String(i + 1) };
let pages = 1;
try { pages = countLetterPages(replacePlaceholders(String(text), ph), fmt, { fontScale: scale }); }
catch { pages = 1; }
return { index: i + 1, pages };
});
return res.json({ counts });
}
module.exports = { generateFromDirectus, pageCounts };