Directus als Daten-/Auth-Schicht + Backend-Anbindung

Directus (Postgres, hinter NPM) wird die zentrale Datenquelle für Kunden,
Aufträge, Produkte, Preise und Gutscheine; das skrift-backend bleibt reine
Generierungs-Engine.

- Docker/directus: docker-compose (Directus 11 + Postgres), Bootstrap-Skript
  legt Collections, Rollen/Policies/Rechte und Seed idempotent an.
- Extension skrift-auth: passwortloser Login per E-Mail-Code (Codes nur als
  Hash, Ablauf, Versuchslimit) sowie Kontoanlage aus dem Checkout.
- Extension skrift-orders: kontrollierter Bestell-Endpunkt für Gäste und
  eingeloggte Kunden; Preis wird immer serverseitig berechnet (editierbare
  Formel via sicherem Interpreter, Werte aus price_items).
- skrift-backend: neuer Endpunkt /api/order/from-directus erzeugt SVGs aus
  einem Directus-Auftrag und schreibt Status + artifact_path zurück. Dafür
  wurde die Kernlogik aus generateOrder als runGeneration herausgelöst
  (Verhalten unverändert); generateOrder ist jetzt ein HTTP-Wrapper.
- compose: NPM-Netz ergänzt, API_TOKEN/DIRECTUS_* werden durchgereicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-07-20 19:13:35 +02:00
parent 0341c4eaea
commit 2dbbc4611a
17 changed files with 1571 additions and 24 deletions

View File

@@ -19,3 +19,8 @@ PORT=4000
# API Authentication (optional)
API_TOKEN=your-api-token-here
# Directus (Datenquelle für Aufträge)
# Token = statischer Token eines Nutzers in der Rolle "Service"
DIRECTUS_URL=https://admin.skrift.de
DIRECTUS_TOKEN=your-directus-service-token

View File

@@ -15,12 +15,16 @@ services:
- BATCH_SIZE=${BATCH_SIZE:-30}
- CACHE_LIFETIME_HOURS=${CACHE_LIFETIME_HOURS:-2}
- RATE_LIMIT_PER_MINUTE=${RATE_LIMIT_PER_MINUTE:-2}
- API_TOKEN=${API_TOKEN}
- DIRECTUS_URL=${DIRECTUS_URL}
- DIRECTUS_TOKEN=${DIRECTUS_TOKEN}
volumes:
- ./fonts:/app/fonts:ro # SVG Fonts (read-only)
- skrift-cache:/app/cache # Preview cache (temporary)
- /var/skrift-output:/app/output # Output files for N8N
networks:
- skrift-network
- npm
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:4000/health"]
interval: 30s
@@ -35,3 +39,8 @@ volumes:
networks:
skrift-network:
driver: bridge
# Netz des Nginx Proxy Managers – damit NPM den Container per
# container_name (skrift-backend:4000) erreichen kann.
npm:
name: nginx-proxy-manager_default
external: true

View File

@@ -0,0 +1,158 @@
/**
* 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');
// 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';
const letterFormat = LETTER_FORMAT[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';
const documents = [];
entries.forEach((entry, i) => {
const placeholders = placeholdersOf(entry);
documents.push({
index: i,
type: 'letter',
text: order.text_template || '',
format: letterFormat,
font,
placeholders,
});
if (wantsEnvelope) {
documents.push({
index: i,
type: 'envelope',
envelopeType,
text: envelopeType === 'recipient' ? addressText(entry) : (order.text_template || ''),
format: envFormat,
font,
placeholders,
});
}
});
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);
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,
},
});
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);
}
}
}
module.exports = { generateFromDirectus };

View File

@@ -138,23 +138,19 @@ async function finalizeOrder(req, res, next) {
* POST /api/order/generate
* Generate order from scratch without using cache
*/
async function generateOrder(req, res, next) {
try {
const { orderNumber, letters } = req.body;
async function runGeneration({ orderNumber, letters, metadata }) {
{
// Validation
if (!orderNumber) {
return res.status(400).json({
error: 'Invalid request',
message: 'orderNumber is required'
});
const e = new Error('orderNumber is required');
e.status = 400;
throw e;
}
if (!letters || !Array.isArray(letters) || letters.length === 0) {
return res.status(400).json({
error: 'Invalid request',
message: 'Letters array is required and must not be empty'
});
const e = new Error('Letters array is required and must not be empty');
e.status = 400;
throw e;
}
console.log(`[Order] Generating order: ${orderNumber}`);
@@ -255,10 +251,10 @@ async function generateOrder(req, res, next) {
}
} catch (err) {
console.error(`[Order] Scriptalization failed for font ${font}:`, err.message);
return res.status(500).json({
error: 'Scriptalization failed',
message: `Failed to scriptalize texts with font ${font}: ${err.message}`
});
const e = new Error(`Failed to scriptalize texts with font ${font}: ${err.message}`);
e.status = 500;
e.label = 'Scriptalization failed';
throw e;
}
}
@@ -290,10 +286,10 @@ async function generateOrder(req, res, next) {
}
} catch (err) {
console.error(`[Order] SVG generation failed for letter ${i}:`, err.message);
return res.status(500).json({
error: 'SVG generation failed',
message: `Failed to generate SVG for letter ${i}: ${err.message}`
});
const e = new Error(`Failed to generate SVG for letter ${i}: ${err.message}`);
e.status = 500;
e.label = 'SVG generation failed';
throw e;
}
// Save SVG file - unterscheide zwischen Briefen und Umschlägen
@@ -352,7 +348,7 @@ async function generateOrder(req, res, next) {
}
// Step 6: Create order metadata
const meta = req.body.metadata || {};
const meta = metadata || {};
const orderMetadata = {
orderNumber,
generatedAt: new Date().toISOString(),
@@ -373,8 +369,7 @@ async function generateOrder(req, res, next) {
console.log(`[Order] Order ${orderNumber} generated successfully`);
// Response
res.status(200).json({
return {
orderNumber,
outputPath: outputDir,
letters: generatedLetters,
@@ -382,9 +377,26 @@ async function generateOrder(req, res, next) {
csvFiles,
totalFiles: generatedLetters.length + generatedEnvelopes.length + csvFiles.length,
timestamp: orderMetadata.generatedAt
});
};
}
}
/**
* POST /api/order/generate
* HTTP-Wrapper um runGeneration – Verhalten unverändert.
*/
async function generateOrder(req, res, next) {
try {
const { orderNumber, letters, metadata } = req.body;
const result = await runGeneration({ orderNumber, letters, metadata });
res.status(200).json(result);
} catch (err) {
if (err.status) {
return res.status(err.status).json({
error: err.label || 'Invalid request',
message: err.message
});
}
console.error('[Order] Error generating order:', err);
next(err);
}
@@ -535,6 +547,7 @@ async function registerOrder(req, res, next) {
module.exports = {
finalizeOrder,
generateOrder,
runGeneration,
uploadMotif,
uploadOrderFile,
registerOrder,

View File

@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router();
const multer = require('multer');
const orderController = require('../controllers/order-controller');
const directusController = require('../controllers/directus-controller');
const { authenticateApiToken } = require('../middleware/auth');
// Max. Uploadgröße für Auftragsdateien (Brief-PDF, Adress-/Platzhalter-Tabellen).
@@ -132,4 +133,15 @@ router.post('/files', authenticateApiToken, uploadOrderFile.single('file'), orde
*/
router.post('/register', authenticateApiToken, orderController.registerOrder);
/**
* POST /api/order/from-directus
* Generiert einen Auftrag, dessen Daten in Directus liegen.
*
* Request body: { orderId: string|number } (ID des Directus-Auftrags)
*
* Antwortet mit 202 (accepted); die Generierung läuft im Hintergrund und
* schreibt Status + artifact_path selbstständig nach Directus zurück.
*/
router.post('/from-directus', authenticateApiToken, directusController.generateFromDirectus);
module.exports = router;

View File

@@ -44,6 +44,12 @@ module.exports = {
apiToken: process.env.API_TOKEN || null
},
// Directus ist die Datenquelle für Aufträge (Rolle "Service"-Token).
directus: {
url: process.env.DIRECTUS_URL || '',
token: process.env.DIRECTUS_TOKEN || ''
},
paypal: {
clientId: process.env.PAYPAL_CLIENT_ID || '',
clientSecret: process.env.PAYPAL_CLIENT_SECRET || '',

View File

@@ -0,0 +1,80 @@
/**
* Directus-Client für das Backend.
*
* Directus ist die Datenquelle für Aufträge; dieses Backend bleibt die reine
* Generierungs-Engine. Zugriff über einen Service-Token (Rolle "Service").
*/
const config = require('../config');
function requireConfig() {
if (!config.directus.url || !config.directus.token) {
const e = new Error('DIRECTUS_URL und DIRECTUS_TOKEN müssen gesetzt sein.');
e.status = 500;
throw e;
}
}
async function directusFetch(path, options = {}) {
requireConfig();
const res = await fetch(config.directus.url.replace(/\/$/, '') + path, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.directus.token}`,
...(options.headers || {}),
},
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { /* ignore */ }
if (!res.ok) {
const msg = data?.errors?.[0]?.message || text || res.statusText;
const e = new Error(`Directus ${options.method || 'GET'} ${path}: ${msg}`);
e.status = res.status === 404 ? 404 : 502;
throw e;
}
return data?.data ?? data;
}
/** Lädt einen Auftrag inkl. Empfängerzeilen, Produkt und Format. */
async function fetchOrder(orderId) {
const fields = [
'id', 'order_number', 'customer_email', 'font', 'status',
'needs_envelope', 'envelope_labeling', 'envelope_format',
'text_template', 'motif_mode', 'own_cards', 'entries_count',
'product.key', 'product.type', 'format.key',
].join(',');
const order = await directusFetch(`/items/orders/${encodeURIComponent(orderId)}?fields=${fields}`);
if (!order) {
const e = new Error(`Auftrag ${orderId} nicht gefunden.`);
e.status = 404;
throw e;
}
const entries = await directusFetch(
`/items/order_entries?filter[order][_eq]=${encodeURIComponent(orderId)}` +
`&sort=letter_number&limit=-1&fields=*`
);
return { order, entries: entries || [] };
}
/** Schreibt Status und Artefakt-Pfad zurück. */
async function updateOrder(orderId, patch) {
return directusFetch(`/items/orders/${encodeURIComponent(orderId)}`, {
method: 'PATCH',
body: JSON.stringify(patch),
});
}
/** Protokolliert einen Statuswechsel in der Timeline. */
async function addStatusHistory(orderId, status, note) {
return directusFetch('/items/status_history', {
method: 'POST',
body: JSON.stringify({ order: orderId, status, note }),
});
}
module.exports = { fetchOrder, updateOrder, addStatusHistory };