Import aus Monorepo (frischer Start)

This commit is contained in:
Lucas Orth
2026-08-24 12:13:58 +02:00
commit 94e79f12ac
34 changed files with 7438 additions and 0 deletions

View File

@@ -0,0 +1,572 @@
/**
* 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 || '';
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: &lt;API_KEY&gt;</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 &gt; 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: &lt;KEY&gt;" -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: &lt;KEY&gt;" -H "Content-Type: application/json" \\
-d '{"document_format":"a4","letter":{"files":["&lt;file_id&gt;"]},
"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: &lt;KEY&gt;" -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: &lt;KEY&gt;"
{ "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: &lt;KEY&gt;" -H "Content-Type: application/json" \\
-d '{"filename":"letter.pdf","content_base64":"&lt;BASE64&gt;"}'
{ "file_id": "..." }</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/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;
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' });
} 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' } } } } } } },
},
},
},
};
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 };

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,13 @@
{
"name": "directus-extension-skrift-api",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Public-API für Kunden (v1): Aufträge anlegen, Status abfragen, Datei-Upload. Key-Auth über api_clients (SHA-256).",
"directus:extension": {
"type": "endpoint",
"path": "dist/index.js",
"source": "dist/index.js",
"host": "^11.0.0"
}
}

View File

@@ -0,0 +1,37 @@
/**
* Skrift – API-Key-Hook
* ---------------------------------------------------------------------------
* Beim Anlegen eines api_clients-Eintrags wird automatisch ein API-Key erzeugt:
* - key_hash = SHA-256 des Keys (dauerhaft; nur damit authentifiziert die API)
* - key_prefix = Erkennungspräfix (Anzeige)
* - key_plain = Klartext-Key, EINMALIG sichtbar (jetzt kopieren)
*
* Bei der nächsten Änderung des Eintrags wird key_plain wieder geleert
* („reveal once"). Der Klartext wird also nie dauerhaft gespeichert.
*/
import crypto from 'node:crypto';
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
export default (register) => {
// Anlegen: Key erzeugen, sofern noch keiner gesetzt ist.
register.filter('api_clients.items.create', (payload) => {
if (payload && typeof payload === 'object' && !payload.key_hash) {
const key = `sk_live_${crypto.randomBytes(24).toString('base64url')}`;
payload.key_hash = sha256(key);
payload.key_prefix = key.slice(0, 14);
payload.key_plain = key; // einmalige Anzeige
if (payload.active === undefined) payload.active = true;
}
return payload;
});
// Jede spätere Änderung entfernt den Klartext (nur direkt nach Anlage sichtbar),
// außer er wird gerade explizit gesetzt.
register.filter('api_clients.items.update', (payload) => {
if (payload && typeof payload === 'object' && payload.key_plain === undefined) {
payload.key_plain = null;
}
return payload;
});
};

View File

@@ -0,0 +1,13 @@
{
"name": "directus-extension-skrift-apikey",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Erzeugt beim Anlegen eines api_clients automatisch einen API-Key (SHA-256 gespeichert, Klartext einmalig in key_plain).",
"directus:extension": {
"type": "hook",
"path": "dist/index.js",
"source": "dist/index.js",
"host": "^11.0.0"
}
}

View File

@@ -0,0 +1,276 @@
/**
* Skrift – Passwortloser Login per E-Mail-Code
* ---------------------------------------------------------------------------
* Directus kennt ab Werk nur Passwort/SSO. Diese Endpoint-Extension ergänzt den
* Kunden-Login per Einmalcode:
*
* POST /skrift-auth/request-code { email } → Code per Mail
* POST /skrift-auth/verify-code { email, code } → Tokens
* POST /skrift-auth/register { email, ... } → Konto anlegen + Code
*
* Sicherheitsprinzipien:
* - `request-code` antwortet IMMER generisch (verrät nicht, ob es das Konto gibt).
* - Codes werden nur als Hash gespeichert (SHA-256 + SECRET als Pepper).
* - Ablauf nach 10 Minuten, max. 5 Fehlversuche, 60 s Wiederhol-Sperre.
*
* Token-Ausstellung: Der Code-Login setzt für den Nutzer ein frisches Zufalls-
* passwort und meldet ihn damit über Directus' offiziellen AuthenticationService
* an. So sind Session/Refresh-Handling garantiert korrekt (statt selbstgebauter
* JWTs) – das Passwort kennt niemand und rotiert bei jedem Login.
*/
import crypto from 'node:crypto';
const CODE_TTL_MIN = 10;
const MAX_ATTEMPTS = 5;
const RESEND_COOLDOWN_SEC = 60;
const CUSTOMER_ROLE_NAME = 'Kunde';
const handler = (router, { services, getSchema, env, logger }) => {
const { ItemsService, MailService, UsersService, AuthenticationService, RolesService } = services;
const norm = (e) => String(e || '').trim().toLowerCase();
const isEmail = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
const hashCode = (code, email) =>
crypto.createHash('sha256').update(`${code}:${email}:${env.SECRET}`).digest('hex');
const genCode = () => String(crypto.randomInt(0, 1_000_000)).padStart(6, '0');
// Temporäres Passwort, das jede übliche Passwort-Richtlinie erfüllt
// (Groß-, Kleinbuchstabe, Ziffer, Sonderzeichen). Wird nie angezeigt und
// rotiert bei jedem Login.
const tempPasswort = () =>
`Aa1!${crypto.randomBytes(24).toString('base64url').replace(/[^A-Za-z0-9]/g, '')}`;
const ctx = async () => ({ schema: await getSchema(), accountability: null });
// Findet das Konto per primärer E-Mail ODER per zusätzlicher Login-Mail
// (alt_emails). So melden sich Zweit-Mails im SELBEN Konto an.
async function findUserByEmail(email, schema) {
const users = new UsersService({ schema, accountability: null });
const felder = ['id', 'email', 'status', 'alt_emails'];
const primaer = await users.readByQuery({ filter: { email: { _eq: email } }, fields: felder, limit: 1 });
if (primaer?.[0]) return primaer[0];
// Zusätzliche Login-Mails: JSON-Feld ist DB-abhängig schwer filterbar, daher
// die (wenigen) Konten mit alt_emails laden und exakt in JS prüfen.
try {
const cand = await users.readByQuery({ filter: { alt_emails: { _nnull: true } }, fields: felder, limit: -1 });
const treffer = (cand || []).find((u) => {
const liste = Array.isArray(u.alt_emails)
? u.alt_emails
: (typeof u.alt_emails === 'string' ? (() => { try { return JSON.parse(u.alt_emails); } catch { return []; } })() : []);
return Array.isArray(liste) && liste.map((x) => norm(x)).includes(email);
});
if (treffer) return treffer;
} catch { /* alt_emails evtl. noch nicht vorhanden → ignorieren */ }
return null;
}
async function sendCodeMail(email, code) {
const schema = await getSchema();
// Standardinhalt (Fallback, falls keine Vorlage hinterlegt ist).
let subject = `Ihr Anmeldecode: ${code}`;
let body =
`<p>Guten Tag,</p><p>Ihr Anmeldecode für Skrift lautet:</p>` +
`<p style="font-size:28px;font-weight:700;letter-spacing:4px;margin:16px 0;">${code}</p>` +
`<p>Der Code ist ${CODE_TTL_MIN} Minuten gültig. Falls Sie keine Anmeldung ` +
`angefordert haben, können Sie diese E-Mail ignorieren.</p>`;
let signature = '';
let absender = '';
// Editierbare Vorlage „login_code" + gemeinsame Signatur laden (falls vorhanden).
try {
const rows = await new ItemsService('email_templates', { schema, accountability: null }).readByQuery({
filter: { key: { _eq: 'login_code' }, enabled: { _eq: true } }, limit: 1, fields: ['subject', 'body'],
});
const tpl = rows?.[0];
const fuelle = (s) => String(s || '').replace(/\{\{(\w+)\}\}/g, (_, k) => (k === 'code' ? code : ''));
if (tpl) {
if (tpl.subject) subject = fuelle(tpl.subject);
if (tpl.body) body = fuelle(tpl.body);
}
const es = await new ItemsService('email_settings', { schema, accountability: null })
.readSingleton({ fields: ['signature', 'sender_name', 'sender_email'] });
if (es?.signature) signature = es.signature;
if (es?.sender_email) {
absender = es.sender_name ? `${es.sender_name} <${es.sender_email}>` : es.sender_email;
}
} catch { /* Vorlage optional – Fallback oben greift */ }
const mail = new MailService({ schema, accountability: null });
const nachricht = { to: email, subject, html: body + signature };
if (absender) nachricht.from = absender;
await mail.send(nachricht);
}
// ── Code anfordern ────────────────────────────────────────────────────────
router.post('/request-code', async (req, res) => {
const email = norm(req.body?.email);
// Antwort ist bewusst immer gleich – keine Auskunft über Kontoexistenz.
const generic = () => res.json({ ok: true });
if (!isEmail(email)) return res.status(400).json({ error: 'Ungültige E-Mail-Adresse.' });
try {
const { schema } = await ctx();
const codes = new ItemsService('login_codes', { schema, accountability: null });
// Wiederhol-Sperre
const recent = await codes.readByQuery({
filter: { email: { _eq: email } },
sort: ['-date_created'], limit: 1, fields: ['date_created'],
});
if (recent?.[0]?.date_created) {
const age = (Date.now() - new Date(recent[0].date_created).getTime()) / 1000;
if (age < RESEND_COOLDOWN_SEC) return generic();
}
const user = await findUserByEmail(email, schema);
if (!user || user.status !== 'active') return generic();
const code = genCode();
await codes.createOne({
email,
code_hash: hashCode(code, email),
expires_at: new Date(Date.now() + CODE_TTL_MIN * 60_000).toISOString(),
attempts: 0,
used: false,
});
await sendCodeMail(email, code);
return generic();
} catch (err) {
logger.error(`[skrift-auth] request-code: ${err.message}`);
return generic();
}
});
// ── Code prüfen + anmelden ────────────────────────────────────────────────
router.post('/verify-code', async (req, res) => {
const email = norm(req.body?.email);
const code = String(req.body?.code || '').trim();
if (!isEmail(email) || !/^\d{6}$/.test(code))
return res.status(400).json({ error: 'E-Mail oder Code ungültig.' });
try {
const { schema } = await ctx();
const codes = new ItemsService('login_codes', { schema, accountability: null });
const rows = await codes.readByQuery({
filter: { email: { _eq: email }, used: { _eq: false } },
sort: ['-date_created'], limit: 1,
fields: ['id', 'code_hash', 'expires_at', 'attempts'],
});
const row = rows?.[0];
if (!row) return res.status(401).json({ error: 'Code ungültig oder abgelaufen.' });
if (new Date(row.expires_at).getTime() < Date.now()) {
await codes.updateOne(row.id, { used: true });
return res.status(401).json({ error: 'Code abgelaufen. Bitte neu anfordern.' });
}
if ((row.attempts ?? 0) >= MAX_ATTEMPTS) {
await codes.updateOne(row.id, { used: true });
return res.status(429).json({ error: 'Zu viele Fehlversuche. Bitte neuen Code anfordern.' });
}
const expected = Buffer.from(row.code_hash, 'utf8');
const actual = Buffer.from(hashCode(code, email), 'utf8');
const ok = expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
if (!ok) {
await codes.updateOne(row.id, { attempts: (row.attempts ?? 0) + 1 });
return res.status(401).json({ error: 'Code ungültig.' });
}
await codes.updateOne(row.id, { used: true });
const user = await findUserByEmail(email, schema);
if (!user || user.status !== 'active')
return res.status(401).json({ error: 'Kein aktives Konto zu dieser Adresse.' });
// WICHTIG: Directus kennt nur die PRIMÄRE E-Mail als Login. Wurde der Code
// an eine Zweit-Mail geschickt, melden wir dennoch mit der primären an –
// dadurch landet die Zweit-Mail im selben Konto (voller Zugriff).
const loginEmail = norm(user.email);
// Offizieller Login-Pfad mit rotierendem Zufallspasswort (s. Kopfkommentar).
const users = new UsersService({ schema, accountability: null });
const tempPassword = tempPasswort();
await users.updateOne(user.id, { password: tempPassword });
// Die Signatur von AuthenticationService.login variiert zwischen
// Directus-Versionen. Der Aufruf mit ('default', payload) hat bei dir per
// curl funktioniert – daher zuerst diese Variante, dann Fallbacks.
const auth = new AuthenticationService({ schema, accountability: null });
const versuche = [
() => auth.login('default', { email: loginEmail, password: tempPassword }),
() => auth.login({ email: loginEmail, password: tempPassword }),
() => auth.login({ email: loginEmail, password: tempPassword }, {}),
];
let result, letzterFehler;
for (const versuch of versuche) {
try { result = await versuch(); break; }
catch (e) { letzterFehler = e; }
}
if (!result) {
logger.error(`[skrift-auth] login fehlgeschlagen: ${letzterFehler?.message}`);
const e = new Error(`Login-Service: ${letzterFehler?.message || 'unbekannt'}`);
e.detail = letzterFehler?.message;
throw e;
}
// Kunde = Login-Konto selbst – keine separate Verknüpfung mehr nötig.
return res.json({
access_token: result.accessToken ?? result.access_token,
refresh_token: result.refreshToken ?? result.refresh_token,
expires: result.expires,
});
} catch (err) {
logger.error(`[skrift-auth] verify-code: ${err.stack || err.message}`);
return res.status(500).json({ error: 'Anmeldung fehlgeschlagen.' });
}
});
// ── Konto anlegen (aus dem Checkout) ──────────────────────────────────────
router.post('/register', async (req, res) => {
const email = norm(req.body?.email);
if (!isEmail(email)) return res.status(400).json({ error: 'Ungültige E-Mail-Adresse.' });
try {
const { schema } = await ctx();
// Bereits vorhanden? Dann einfach Login-Code schicken (kein Fehler).
const existing = await findUserByEmail(email, schema);
if (existing) return res.json({ ok: true, existed: true });
const roles = new RolesService({ schema, accountability: null });
const role = (await roles.readByQuery({
filter: { name: { _eq: CUSTOMER_ROLE_NAME } }, limit: 1, fields: ['id'],
}))?.[0];
if (!role) {
logger.error(`[skrift-auth] Rolle "${CUSTOMER_ROLE_NAME}" nicht gefunden.`);
return res.status(500).json({ error: 'Konto konnte nicht angelegt werden.' });
}
// Kunde = Login-Konto: Profil hängt direkt am Nutzer.
const users = new UsersService({ schema, accountability: null });
await users.createOne({
email,
password: crypto.randomBytes(32).toString('base64url'),
role: role.id,
status: 'active',
first_name: req.body?.first_name ?? null,
last_name: req.body?.last_name ?? null,
company: req.body?.company ?? null,
phone: req.body?.phone ?? null,
person_type: req.body?.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
});
return res.json({ ok: true, existed: false });
} catch (err) {
logger.error(`[skrift-auth] register: ${err.message}`);
return res.status(500).json({ error: 'Konto konnte nicht angelegt werden.' });
}
});
};
// Explizite ID → Routen liegen garantiert unter /skrift-auth/*,
// unabhängig von Ordner- oder Paketnamen.
export default { id: 'skrift-auth', handler };

View File

@@ -0,0 +1,13 @@
{
"name": "directus-extension-skrift-auth",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Passwortloser Login per E-Mail-Code für Skrift-Kunden.",
"directus:extension": {
"type": "endpoint",
"path": "dist/index.js",
"source": "dist/index.js",
"host": "^11.0.0"
}
}

View File

@@ -0,0 +1,205 @@
/**
* Skrift – Kontakte-Hook (Anlage auf Anforderung, Aktionen, DSGVO-Purge)
* ---------------------------------------------------------------------------
* Rund um die Collections `contacts` und `contact_actions`:
*
* 1) ANLAGE NUR AUF ANFORDERUNG — Kontakte werden NICHT automatisch bei jedem
* Versand gespeichert. Erst wenn am Auftrag „In Kontakte speichern"
* (orders.keep_contacts) gesetzt wird, entsteht je Empfängerzeile ein
* Kontakt (Guard: orders.contacts_created). Diese Kontakte bleiben dauerhaft.
*
* 2) STANDARDAKTIONEN (contact_actions) —
* • type „angebot" → sofort eine Mail mit den Eckdaten (Name, Adresse,
* E-Mail, Telefon, Notizen) an die interne Adresse (fürs Rechnungsprogramm).
* • type „rueckruf" → am fälligen Tag (Zeitplan) eine Erinnerungs-Mail.
*
* 3) DSGVO-STRIP (optional, per Env) — Empfängerdaten (order_entries) alter,
* versendeter Aufträge OHNE „In Kontakte speichern" werden anonymisiert.
* Standard: nur DRY-RUN (Log). Scharf via SKRIFT_CONTACTS_STRIP_ENTRIES=1.
* Kontakte selbst werden NICHT automatisch gelöscht (nur manuell).
*
* Env:
* SKRIFT_CONTACTS_REMINDER_CRON Cron der Rückruf-Erinnerung (Default „0 7 * * *").
* SKRIFT_CONTACTS_PURGE_CRON Cron des DSGVO-Strip (Default „0 3 * * *").
* SKRIFT_CONTACTS_STRIP_ENTRIES „1"/„true" = Empfängerdaten wirklich löschen.
* SKRIFT_TEAM_EMAIL / EMAIL_FROM Fallback-Empfänger, falls email_settings.team_email leer.
*
* Von Hand als ESM (kein Build-Schritt), analog zu skrift-mailer/skrift-apikey.
*/
const RETENTION_DAYS = 30;
// Alle 15 Minuten prüfen, damit die Erinnerung nahe der gewählten Uhrzeit kommt.
const REMINDER_CRON = process.env.SKRIFT_CONTACTS_REMINDER_CRON || '*/15 * * * *';
const PURGE_CRON = process.env.SKRIFT_CONTACTS_PURGE_CRON || '0 3 * * *';
const STRIP_ENTRIES = /^(1|true|yes|on)$/i.test(String(process.env.SKRIFT_CONTACTS_STRIP_ENTRIES || ''));
const ENTRY_PII = ['salutation', 'first_name', 'last_name', 'street', 'house_no',
'zip', 'city', 'country', 'free_text', 'placeholders', 'text'];
const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
const htmlZuText = (html) => String(html || '')
.replace(/<\s*br\s*\/?>/gi, '\n').replace(/<\/(p|div|tr|h[1-6]|li|table)>/gi, '\n')
.replace(/<[^>]+>/g, '').replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&').replace(/&lt;/gi, '<').replace(/&gt;/gi, '>')
.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
const contactName = (c) => [c?.salutation, c?.first_name, c?.last_name].filter(Boolean).join(' ')
|| c?.company || c?.email || c?.phone || '(ohne Namen)';
// Eckdaten eines Kontakts als HTML-Block (für Angebots-/Erinnerungs-Mails).
function contactHtml(c, extraNote) {
const anschrift = [
c.company ? `<strong>${esc(c.company)}</strong>` : '',
esc([c.salutation, c.first_name, c.last_name].filter(Boolean).join(' ')),
esc([c.street, c.house_no].filter(Boolean).join(' ')),
esc([c.zip, c.city].filter(Boolean).join(' ')),
c.country ? esc(c.country) : '',
].filter(Boolean).join('<br>');
const zeilen = [];
zeilen.push(`<p style="margin:0 0 4px;font-weight:600;">Anschrift</p><p style="margin:0 0 12px;line-height:1.6;">${anschrift || '—'}</p>`);
zeilen.push('<p style="margin:0 0 12px;">'
+ `E-Mail: ${c.email ? esc(c.email) : '—'}<br>Telefon: ${c.phone ? esc(c.phone) : '—'}</p>`);
const notes = [c.notes, extraNote].filter((n) => n && String(n).trim()).join('\n\n');
if (notes) zeilen.push(`<p style="margin:0 0 4px;font-weight:600;">Notizen</p><p style="margin:0;line-height:1.6;white-space:pre-wrap;">${esc(notes)}</p>`);
return zeilen.join('');
}
export default (register, { services, getSchema, logger }) => {
const { ItemsService, MailService } = services;
const svc = (coll, schema) => new ItemsService(coll, { schema, accountability: null });
async function loadSettings(schema) {
try { const rows = await svc('email_settings', schema).readByQuery({ limit: 1 }); return rows?.[0] || {}; }
catch { return {}; }
}
async function sendTeamMail(schema, subject, html) {
const settings = await loadSettings(schema);
const to = settings.team_email || process.env.SKRIFT_TEAM_EMAIL || process.env.EMAIL_FROM;
if (!to) { logger.warn('[skrift-contacts] keine Team-Adresse (email_settings.team_email) – Mail übersprungen'); return; }
const msg = { to, subject, html, text: htmlZuText(html) };
if (settings.sender_email) msg.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
await new MailService({ schema, accountability: null }).send(msg);
logger.info(`[skrift-contacts] Mail „${subject}" an ${to}`);
}
// ── 1) Kontakt-Anlage NUR wenn „In Kontakte speichern" gesetzt wird ─────────
function contactFromEntry(order, e) {
const c = {
salutation: e.salutation || null, first_name: e.first_name || null, last_name: e.last_name || null,
street: e.street || null, house_no: e.house_no || null, zip: e.zip || null, city: e.city || null,
country: e.country || null, lead_status: 'neu',
source_order: order.id, source_entry: e.id, sent_at: order.versandt_am || null,
};
if (!c.first_name && !c.last_name && e.free_text) {
const lines = String(e.free_text).split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
if (lines.length) c.last_name = lines[0];
if (lines.length) c.notes = 'Adresse:\n' + lines.join('\n');
}
return c;
}
async function saveContactsForOrder(orderId) {
const schema = await getSchema();
const orders = svc('orders', schema);
const order = await orders.readOne(orderId, {
fields: ['id', 'order_number', 'versandt_am', 'contacts_created'],
});
if (!order) return;
if (order.contacts_created) return; // schon angelegt → nichts weiter zu tun
const entries = await svc('order_entries', schema).readByQuery({
filter: { order: { _eq: orderId } }, limit: -1,
fields: ['id', 'salutation', 'first_name', 'last_name', 'street', 'house_no', 'zip', 'city', 'country', 'free_text'],
});
if (entries?.length) await svc('contacts', schema).createMany(entries.map((e) => contactFromEntry(order, e)));
await orders.updateOne(orderId, { contacts_created: true });
logger.info(`[skrift-contacts] ${entries?.length || 0} Kontakt(e) aus Auftrag ${order.order_number} gespeichert`);
}
register.action('orders.items.update', async ({ keys, payload }) => {
if (payload?.keep_contacts !== true) return; // nur auf ausdrückliche Anforderung
for (const id of keys || []) {
try { await saveContactsForOrder(id); }
catch (e) { logger.warn(`[skrift-contacts] speichern ${id}: ${e.message}`); }
}
});
// ── 2) Standardaktionen ─────────────────────────────────────────────────────
register.action('contact_actions.items.create', async ({ key, payload }) => {
const typ = payload?.type;
if (typ !== 'angebot' && typ !== 'mail') return; // Rückruf läuft über den Zeitplan
try {
const schema = await getSchema();
const a = await svc('contact_actions', schema).readOne(key, {
fields: ['id', 'note', 'contact.salutation', 'contact.first_name', 'contact.last_name', 'contact.company',
'contact.street', 'contact.house_no', 'contact.zip', 'contact.city', 'contact.country',
'contact.email', 'contact.phone', 'contact.notes'],
});
if (!a?.contact) return;
const name = contactName(a.contact);
const betreff = typ === 'mail' ? `Notiz: ${name}` : `Angebot vorbereiten: ${name}`;
const einleitung = typ === 'mail' ? `Notiz zu <strong>${esc(name)}</strong>:` : `Angebot vorbereiten für <strong>${esc(name)}</strong>:`;
const html = `<p style="margin:0 0 12px;">${einleitung}</p>${contactHtml(a.contact, a.note)}`;
await sendTeamMail(schema, betreff, html);
} catch (e) { logger.warn(`[skrift-contacts] Aktions-Mail (${typ}): ${e.message}`); }
});
register.schedule(REMINDER_CRON, async () => {
try {
const schema = await getSchema();
const nowIso = new Date().toISOString();
const actions = svc('contact_actions', schema);
const due = await actions.readByQuery({
filter: { _and: [{ type: { _eq: 'rueckruf' } }, { status: { _eq: 'offen' } }, { notified: { _eq: false } }, { due_date: { _lte: nowIso } }] },
fields: ['id', 'note', 'due_date', 'contact.salutation', 'contact.first_name', 'contact.last_name', 'contact.company',
'contact.street', 'contact.house_no', 'contact.zip', 'contact.city', 'contact.country', 'contact.email', 'contact.phone', 'contact.notes'],
limit: -1,
});
for (const a of due || []) {
if (!a.contact) { await actions.updateOne(a.id, { notified: true }); continue; }
const name = contactName(a.contact);
const wann = a.due_date ? new Date(a.due_date).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'short', timeStyle: 'short' }) : '';
const html = `<p style="margin:0 0 12px;">Fälliger Rückruf${wann ? ' (' + esc(wann) + ' Uhr)' : ''} bei <strong>${esc(name)}</strong>`
+ `${a.contact.phone ? ' · <strong>' + esc(a.contact.phone) + '</strong>' : ''}:</p>${contactHtml(a.contact, a.note)}`;
try { await sendTeamMail(schema, `Rückruf fällig: ${name}`, html); await actions.updateOne(a.id, { notified: true }); }
catch (e) { logger.warn(`[skrift-contacts] Rückruf-Mail ${a.id}: ${e.message}`); }
}
} catch (e) { logger.warn(`[skrift-contacts] Erinnerungslauf: ${e.message}`); }
});
// ── 3) Täglicher Aufräumlauf ────────────────────────────────────────────────
register.schedule(PURGE_CRON, async () => {
try {
const schema = await getSchema();
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400000).toISOString();
// 3a) Kontakte mit „kein Interesse", seit 30 Tagen unverändert → löschen.
// Alle anderen Lead-Status bleiben dauerhaft erhalten.
const contacts = svc('contacts', schema);
const raus = await contacts.readByQuery({
filter: { _and: [{ lead_status: { _eq: 'kein_interesse' } }, { date_updated: { _lt: cutoff } }] },
fields: ['id'], limit: -1,
});
if (raus?.length) { await contacts.deleteMany(raus.map((r) => r.id)); logger.info(`[skrift-contacts] ${raus.length} Kontakt(e) „kein Interesse" (>30 Tage) gelöscht`); }
// 3b) DSGVO-Strip der Empfängerdaten im Auftrag (optional, per Env).
const orders = svc('orders', schema);
const alte = await orders.readByQuery({
filter: { _and: [
{ keep_contacts: { _eq: false } }, { entries_stripped: { _eq: false } },
{ production_status: { _eq: 'versendet' } }, { versandt_am: { _lt: cutoff } },
] },
fields: ['id', 'order_number'], limit: -1,
});
for (const o of alte || []) {
const entries = await svc('order_entries', schema).readByQuery({ filter: { order: { _eq: o.id } }, fields: ['id'], limit: -1 });
if (!STRIP_ENTRIES) {
logger.info(`[skrift-contacts] DSGVO DRY-RUN: würde Empfängerdaten von Auftrag ${o.order_number} (${entries?.length || 0} Zeilen) anonymisieren – scharf via SKRIFT_CONTACTS_STRIP_ENTRIES=1`);
continue;
}
const wipe = Object.fromEntries(ENTRY_PII.map((k) => [k, null]));
if (entries?.length) await svc('order_entries', schema).updateMany(entries.map((r) => r.id), wipe);
await orders.updateOne(o.id, { entries_stripped: true });
logger.info(`[skrift-contacts] DSGVO: Empfängerdaten von Auftrag ${o.order_number} anonymisiert`);
}
} catch (e) { logger.warn(`[skrift-contacts] DSGVO-Lauf: ${e.message}`); }
});
};

View File

@@ -0,0 +1,13 @@
{
"name": "directus-extension-skrift-contacts",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Legt bei Versand automatisch Kontakte je Empfänger an und löscht sie (samt Empfängerdaten im Auftrag) nach 30 Tagen – außer der Auftrag ist als „Kontakte behalten“ markiert.",
"directus:extension": {
"type": "hook",
"path": "dist/index.js",
"source": "dist/index.js",
"host": "^11.0.0"
}
}

View File

@@ -0,0 +1,397 @@
/**
* Skrift – Modul „Kontakte" (Mini-CRM)
* ===========================================================================
* Handytaugliches Lead-Board über die Collection `contacts`: alle Empfänger,
* denen ein Schriftstück gesendet wurde. Fokus auf Nachtelefonieren:
*
* • Liste mit Suche + Status-Filter, ein Tipp öffnet den Kontakt.
* • Detail: Name/Adresse, Telefon als GroundWire-Anruf-Button, E-Mail,
* Lead-Status, „Behalten" und ein Notizfeld – alles mit Auto-Speichern.
* • ◀/▶ blättert durch die gefilterte Liste, ohne zurückzuspringen.
* • CSV-Export der (gefilterten) Kontakte. Import über die normale
* Directus-Collection-Seite (Content → Kontakte → Import).
*
* Anruf-Schema fest GroundWire (groundwire://call?number=…). Die Konstante DIAL
* unten ist die einzige Stelle, falls ein anderes Format nötig wird.
*
* Von Hand als ESM (kein Build-Schritt) – Vue + SDK stellt Directus bereit.
*/
import { defineComponent, ref, reactive, computed, watch, onMounted, h, resolveComponent } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const ACCENT = '#1E50A5';
// GroundWire-Anruf-Link (Acrobits „Call URI"): scheme:NUMMER?dialAction=…
// Die Nummer steht direkt hinter dem Doppelpunkt (kein „//"), ohne Leerzeichen.
// dialAction=voiceCall → sofortiger VoIP-Anruf über GroundWire. Für nur
// Vorbefüllen (Nummer eintippen, selbst tippen) den ?dialAction-Teil weglassen.
const cleanNr = (nr) => String(nr || '').replace(/[^\d+]/g, '');
const DIAL = (nr) => 'groundwire:' + cleanNr(nr) + '?dialAction=voiceCall';
const LEAD = [
{ key: 'neu', label: 'Neu', color: '#6b7280' },
{ key: 'kontaktiert', label: 'Kontaktiert', color: ACCENT },
{ key: 'interessiert', label: 'Interessiert', color: '#e07b00' },
{ key: 'kein_interesse', label: 'Kein Interesse', color: '#c62828' },
{ key: 'kunde', label: 'Kunde', color: '#2e7d32' },
];
const LEAD_LABEL = Object.fromEntries(LEAD.map((l) => [l.key, l.label]));
const LEAD_COLOR = Object.fromEntries(LEAD.map((l) => [l.key, l.color]));
const FIELDS = ['id', 'salutation', 'first_name', 'last_name', 'company', 'street', 'house_no',
'zip', 'city', 'country', 'email', 'phone', 'notes', 'lead_status', 'sent_at',
'source_order.order_number'];
// ── Stil-Helfer ──────────────────────────────────────────────────────────────
const CARD = 'border:1px solid var(--border-subdued);border-radius:10px;padding:16px 18px;background:var(--background-page,#fff);box-shadow:0 1px 3px rgba(0,0,0,.08);';
// Klar erkennbares Eingabefeld: kräftigere Umrandung + leicht getönter Grund,
// damit es sich deutlich von der weißen Karte abhebt.
const S_INPUT = 'width:100%;padding:11px 12px;border:1.5px solid var(--border-normal,#c4cdde);border-radius:8px;background:var(--background-subdued,#eef1f7);color:var(--foreground-normal);font:inherit;font-size:15px;box-sizing:border-box;';
const LBL = 'font-size:12px;font-weight:600;color:var(--foreground-subdued);margin:0 0 4px;text-transform:uppercase;letter-spacing:.03em;';
function btn(label, onClick, kind) {
const base = 'cursor:pointer;border-radius:8px;padding:10px 16px;font:inherit;font-size:14px;border:1px solid;';
const style = kind === 'primary'
? base + `border-color:${ACCENT};background:${ACCENT};color:#fff;`
: kind === 'ghost'
? base + 'border-color:transparent;background:transparent;color:var(--foreground-normal);'
: base + 'border-color:var(--border-normal);background:var(--background-subdued);color:var(--foreground-normal);';
return h('button', { type: 'button', onClick, style }, label);
}
function chip(text, farbe) {
return h('span', {
style: 'display:inline-flex;align-items:center;gap:4px;font-size:12px;padding:3px 10px;border-radius:20px;'
+ `background:${farbe || 'var(--background-subdued)'};color:#fff;white-space:nowrap;`,
}, text);
}
function subdued(text, extra) {
return h('div', { style: `color:var(--foreground-subdued);font-size:14px;${extra || ''}` }, text);
}
function feld(label, node) {
return h('div', { style: 'margin-bottom:14px;' }, [h('div', { style: LBL }, label), node]);
}
const anzeigeName = (k) => [k.salutation, k.first_name, k.last_name].filter(Boolean).join(' ')
|| k.company || k.email || k.phone || '(ohne Namen)';
const adresse = (k) => [[k.street, k.house_no].filter(Boolean).join(' '),
[k.zip, k.city].filter(Boolean).join(' '), k.country].filter(Boolean).join(', ');
const Board = defineComponent({
setup() {
const api = useApi();
const kontakte = ref([]);
const loading = ref(false);
const fehler = ref('');
const meldung = ref('');
const suche = ref('');
const statusFilter = ref('');
const aktivId = ref(null);
const savedFlash = ref(null); // Feld-Key, der zuletzt gespeichert wurde
const aktionen = ref([]); // offene/erledigte Aktionen des aktiven Kontakts
const rueckruf = reactive({ open: false, date: '', time: '09:00' });
const mailMich = reactive({ open: false, note: '' });
async function laden() {
loading.value = true; fehler.value = '';
try {
const r = await api.get('/items/contacts', {
params: { limit: -1, sort: ['-sent_at', '-date_created'], fields: FIELDS },
});
kontakte.value = r.data.data || [];
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Fehler beim Laden.'; }
finally { loading.value = false; }
}
onMounted(laden);
const gefiltert = computed(() => {
const q = suche.value.trim().toLowerCase();
return kontakte.value.filter((k) => {
if (statusFilter.value && (k.lead_status || 'neu') !== statusFilter.value) return false;
if (!q) return true;
const hay = [k.salutation, k.first_name, k.last_name, k.company, k.zip, k.city,
k.email, k.phone, k.source_order?.order_number].filter(Boolean).join(' ').toLowerCase();
return hay.includes(q);
});
});
const aktiv = computed(() => kontakte.value.find((k) => k.id === aktivId.value) || null);
const aktivIndex = computed(() => gefiltert.value.findIndex((k) => k.id === aktivId.value));
async function save(id, patch, feldKey) {
try {
await api.patch(`/items/contacts/${id}`, patch);
const k = kontakte.value.find((x) => x.id === id);
if (k) Object.assign(k, patch);
if (feldKey) { savedFlash.value = feldKey; setTimeout(() => { if (savedFlash.value === feldKey) savedFlash.value = null; }, 1500); }
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Speichern fehlgeschlagen.'; }
}
// Speichert nur, wenn sich der Wert wirklich geändert hat (Blur-Handler).
const saveIfChanged = (k, key) => (ev) => {
const val = ev.target.value;
if ((k[key] || '') !== val) save(k.id, { [key]: val || null }, key);
};
// ── Standardaktionen (Rückruf / Angebot) ─────────────────────────────────
async function ladeAktionen(id) {
if (!id) { aktionen.value = []; return; }
try {
const r = await api.get('/items/contact_actions', {
params: { filter: { contact: { _eq: id } }, sort: ['-date_created'], limit: -1,
fields: ['id', 'type', 'due_date', 'note', 'status', 'notified'] },
});
aktionen.value = r.data.data || [];
} catch { aktionen.value = []; }
}
watch(aktivId, (id) => { rueckruf.open = false; rueckruf.date = ''; rueckruf.time = '09:00'; mailMich.open = false; mailMich.note = ''; ladeAktionen(id); });
const isoDatum = (d) => { const x = new Date(); x.setHours(0, 0, 0, 0); x.setDate(x.getDate() + d); return x; };
const ymd = (dt) => `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
async function planeRueckruf(k) {
if (!rueckruf.date) { fehler.value = 'Bitte ein Datum wählen.'; return; }
try {
await api.post('/items/contact_actions', {
contact: k.id, type: 'rueckruf', status: 'offen',
due_date: new Date(rueckruf.date + 'T' + (rueckruf.time || '09:00') + ':00').toISOString(),
});
rueckruf.open = false; rueckruf.date = ''; rueckruf.time = '09:00';
await ladeAktionen(k.id);
meldung.value = 'Rückruf-Erinnerung gesetzt.'; setTimeout(() => { meldung.value = ''; }, 2500);
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Konnte Rückruf nicht anlegen.'; }
}
async function sendeAngebot(k) {
try {
await api.post('/items/contact_actions', { contact: k.id, type: 'angebot', status: 'erledigt' });
await ladeAktionen(k.id);
meldung.value = 'Angebot-Eckdaten wurden dir per Mail geschickt.'; setTimeout(() => { meldung.value = ''; }, 3500);
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Konnte Angebot nicht senden.'; }
}
// Legt eine Musterbestellung an: erscheint als Musteranfrage in
// Produktion → Neu und wird über den bestehenden „Muster drucken"-Workflow
// gedruckt. Keine Mail an den Kontakt.
async function sendeMuster(k) {
const name = [k.salutation, k.first_name, k.last_name].filter(Boolean).join(' ') || k.company || 'Kontakt';
try {
await api.post('/items/offer_requests', {
type: 'muster', status: 'neu', name, product: 'Muster',
email: k.email || 'muster@skrift.de', phone: k.phone || null,
address: {
company: k.company || '', salutation: k.salutation || '',
first_name: k.first_name || '', last_name: k.last_name || '',
street: [k.street, k.house_no].filter(Boolean).join(' '),
zip: k.zip || '', city: k.city || '', country: k.country || '',
},
});
meldung.value = 'Musterbestellung angelegt – erscheint in Produktion → Neu.'; setTimeout(() => { meldung.value = ''; }, 3500);
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Konnte Muster nicht anlegen.'; }
}
// Schickt dir (interne Adresse) die Eckdaten des Kontakts + deine Notiz per Mail.
async function sendeMail(k) {
if (!mailMich.note.trim()) { fehler.value = 'Bitte eine Notiz eingeben.'; return; }
try {
await api.post('/items/contact_actions', { contact: k.id, type: 'mail', status: 'erledigt', note: mailMich.note });
mailMich.open = false; mailMich.note = '';
meldung.value = 'Deine Notiz wurde dir per Mail geschickt.'; setTimeout(() => { meldung.value = ''; }, 3500);
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Konnte Mail nicht senden.'; }
}
async function erledige(a, k) {
try { await api.patch(`/items/contact_actions/${a.id}`, { status: 'erledigt' }); await ladeAktionen(k.id); }
catch (e) { fehler.value = e?.message || 'Konnte nicht als erledigt markieren.'; }
}
function exportCsv() {
const rows = gefiltert.value;
const cols = [['salutation', 'Anrede'], ['first_name', 'Vorname'], ['last_name', 'Nachname'],
['company', 'Firma'], ['street', 'Straße'], ['house_no', 'Hausnr'], ['zip', 'PLZ'], ['city', 'Ort'],
['country', 'Land'], ['email', 'E-Mail'], ['phone', 'Telefon'], ['lead_status', 'Status'],
['notes', 'Notizen'], ['sent_at', 'Versendet am']];
const esc = (v) => { const s = v == null ? '' : String(v); return /[",\n;]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; };
const csv = '' + cols.map((c) => c[1]).join(',') + '\n'
+ rows.map((r) => cols.map((c) => esc(r[c[0]])).join(',')).join('\n');
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
const a = document.createElement('a');
a.href = url; a.download = `kontakte-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
}
// ── Liste ──
function karte(k) {
return h('div', {
onClick: () => { aktivId.value = k.id; fehler.value = ''; },
style: CARD + 'cursor:pointer;margin-bottom:10px;display:flex;justify-content:space-between;align-items:flex-start;gap:12px;',
}, [
h('div', { style: 'min-width:0;' }, [
h('div', { style: 'font-size:16px;font-weight:600;margin-bottom:2px;' }, anzeigeName(k)),
subdued(adresse(k) || '—', 'font-size:13px;'),
k.phone ? subdued('📞 ' + k.phone, 'font-size:13px;margin-top:2px;') : null,
]),
h('div', { style: 'display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex-shrink:0;' }, [
chip(LEAD_LABEL[k.lead_status || 'neu'], LEAD_COLOR[k.lead_status || 'neu']),
]),
]);
}
function listenAnsicht() {
const kopf = h('div', { style: 'display:flex;flex-direction:column;gap:10px;margin-bottom:16px;' }, [
h('input', {
type: 'search', value: suche.value, placeholder: '🔎 Name, Ort, Telefon, E-Mail …',
style: S_INPUT, onInput: (e) => { suche.value = e.target.value; },
}),
h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;align-items:center;' }, [
h('select', {
style: S_INPUT + 'width:auto;flex:1;min-width:150px;cursor:pointer;',
onChange: (e) => { statusFilter.value = e.target.value; },
}, [
h('option', { value: '', selected: statusFilter.value === '' }, 'Alle Status'),
...LEAD.map((l) => h('option', { value: l.key, selected: statusFilter.value === l.key }, l.label)),
]),
]),
h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;align-items:center;justify-content:space-between;' }, [
subdued(`${gefiltert.value.length} Kontakt(e)`),
h('div', { style: 'display:flex;gap:8px;' }, [
btn('↻ Aktualisieren', laden),
btn('⭳ CSV-Export', exportCsv),
]),
]),
]);
const liste = loading.value ? subdued('Lädt …')
: gefiltert.value.length ? h('div', {}, gefiltert.value.map(karte))
: subdued('Keine Kontakte. Sobald ein Auftrag versendet wird, erscheinen die Empfänger hier automatisch.');
const hinweis = subdued('CSV-Import: Content → Kontakte → Import (oben rechts).', 'margin-top:16px;font-size:12px;');
return [kopf, liste, hinweis];
}
// ── Detail ──
function detailAnsicht() {
const k = aktiv.value;
if (!k) { aktivId.value = null; return listenAnsicht(); }
const idx = aktivIndex.value;
const gesamt = gefiltert.value.length;
const geh = (d) => {
const l = gefiltert.value;
let i = aktivIndex.value; if (i < 0) return;
i += d; if (i < 0 || i >= l.length) return;
aktivId.value = l[i].id; fehler.value = '';
};
const flash = (key) => savedFlash.value === key ? h('span', { style: 'color:#2e7d32;font-size:12px;margin-left:8px;' }, '✓ gespeichert') : null;
const navRow = h('div', { style: 'display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:14px;' }, [
btn('◀ Liste', () => { aktivId.value = null; }, 'ghost'),
h('div', { style: 'display:flex;align-items:center;gap:8px;' }, [
h('button', { type: 'button', disabled: idx <= 0, onClick: () => geh(-1),
style: 'cursor:pointer;border:1px solid var(--border-normal);background:var(--background-subdued);border-radius:8px;padding:8px 12px;font-size:16px;' + (idx <= 0 ? 'opacity:.4;cursor:not-allowed;' : '') }, '◀'),
subdued(gesamt ? `${idx + 1} / ${gesamt}` : '–'),
h('button', { type: 'button', disabled: idx >= gesamt - 1, onClick: () => geh(1),
style: 'cursor:pointer;border:1px solid var(--border-normal);background:var(--background-subdued);border-radius:8px;padding:8px 12px;font-size:16px;' + (idx >= gesamt - 1 ? 'opacity:.4;cursor:not-allowed;' : '') }, '▶'),
]),
]);
const kopf = h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:14px;' }, [
h('div', {}, [
h('div', { style: 'font-size:22px;font-weight:700;' }, anzeigeName(k)),
adresse(k) ? subdued(adresse(k), 'margin-top:2px;') : null,
k.source_order?.order_number ? subdued('aus Auftrag ' + k.source_order.order_number, 'font-size:12px;margin-top:2px;') : null,
]),
chip(LEAD_LABEL[k.lead_status || 'neu'], LEAD_COLOR[k.lead_status || 'neu']),
]);
// Anruf-Button (GroundWire) – nur wenn eine Nummer da ist.
const anruf = k.phone
? h('a', { href: DIAL(k.phone),
style: `display:flex;align-items:center;justify-content:center;gap:8px;text-decoration:none;background:#2e7d32;color:#fff;border-radius:10px;padding:14px;font-size:17px;font-weight:600;margin-bottom:14px;` },
[h('span', {}, '📞'), h('span', {}, 'Anrufen · ' + k.phone)])
: subdued('Keine Telefonnummer – unten eintragen, dann erscheint der Anruf-Button.', 'margin-bottom:14px;');
const telFeld = feld(h('span', {}, ['Telefon', flash('phone')]),
h('input', { type: 'tel', value: k.phone || '', placeholder: '+49 …', style: S_INPUT, onBlur: saveIfChanged(k, 'phone') }));
const mailFeld = feld(h('span', {}, ['E-Mail', flash('email')]),
h('div', { style: 'display:flex;gap:8px;align-items:center;' }, [
h('input', { type: 'email', value: k.email || '', placeholder: 'name@…', style: S_INPUT, onBlur: saveIfChanged(k, 'email') }),
k.email ? h('a', { href: 'mailto:' + k.email, style: 'text-decoration:none;font-size:20px;' }, '✉') : null,
]));
const statusFeld = feld('Lead-Status',
h('select', { style: S_INPUT + 'cursor:pointer;', onChange: (e) => save(k.id, { lead_status: e.target.value }, 'lead_status') },
LEAD.map((l) => h('option', { value: l.key, selected: (k.lead_status || 'neu') === l.key }, l.label))));
const notizFeld = feld(h('span', {}, ['Notizen', flash('notes')]),
h('textarea', { value: k.notes || '', rows: 5, placeholder: 'Gesprächsnotizen …',
style: S_INPUT + 'resize:vertical;line-height:1.5;', onBlur: saveIfChanged(k, 'notes') }));
const namensFelder = h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:10px;' }, [
feld('Vorname', h('input', { value: k.first_name || '', style: S_INPUT, onBlur: saveIfChanged(k, 'first_name') })),
feld('Nachname', h('input', { value: k.last_name || '', style: S_INPUT, onBlur: saveIfChanged(k, 'last_name') })),
feld('Firma', h('input', { value: k.company || '', style: S_INPUT, onBlur: saveIfChanged(k, 'company') })),
feld('Ort', h('input', { value: k.city || '', style: S_INPUT, onBlur: saveIfChanged(k, 'city') })),
]);
// ── Standardaktionen (deutlich als farbige Buttons) ──
const aktBase = 'cursor:pointer;border-radius:8px;padding:11px 15px;font:inherit;font-size:14px;font-weight:600;'
+ 'border:1.5px solid;text-decoration:none;display:inline-flex;align-items:center;gap:6px;';
const aktStil = (rand, grund, text) => aktBase + `border-color:${rand};background:${grund};color:${text};`;
const S_RUECK = aktStil(ACCENT, 'rgba(30,80,165,.10)', ACCENT);
const S_ANGEBOT = aktStil('#e07b00', 'rgba(224,123,0,.12)', '#b35f00');
const S_MUSTER = aktStil('#6b3fa0', 'rgba(107,63,160,.12)', '#5a2f90');
const S_MAIL = aktStil('#0b7285', 'rgba(11,114,133,.12)', '#0b6273');
const aktionsLeiste = h('div', { class: 'sk-akt' }, [
h('button', { type: 'button', style: rueckruf.open ? aktStil(ACCENT, ACCENT, '#fff') : S_RUECK, onClick: () => { rueckruf.open = !rueckruf.open; if (rueckruf.open && !rueckruf.date) rueckruf.date = ymd(isoDatum(1)); } }, '📞 Rückruf planen'),
h('button', { type: 'button', style: S_ANGEBOT, onClick: () => sendeAngebot(k) }, '📧 Angebot senden'),
h('button', { type: 'button', style: S_MUSTER, onClick: () => sendeMuster(k) }, '🧪 Muster senden'),
h('button', { type: 'button', style: mailMich.open ? aktStil('#0b7285', '#0b7285', '#fff') : S_MAIL, onClick: () => { mailMich.open = !mailMich.open; } }, '✉️ Mail an mich'),
]);
const preset = (label, d) => h('button', { type: 'button',
style: 'cursor:pointer;border-radius:20px;padding:6px 12px;font:inherit;font-size:13px;border:1px solid var(--border-normal);'
+ (rueckruf.date === ymd(isoDatum(d)) ? `background:${ACCENT};color:#fff;` : 'background:var(--background-page);color:var(--foreground-normal);'),
onClick: () => { rueckruf.date = ymd(isoDatum(d)); } }, label);
const rueckrufForm = rueckruf.open ? h('div', { style: 'border:1px solid var(--border-normal);border-radius:10px;padding:14px;margin-top:10px;margin-bottom:12px;background:var(--background-subdued);' }, [
h('div', { style: 'display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;' }, [preset('Morgen', 1), preset('In 3 Tagen', 3), preset('Nächste Woche', 7)]),
h('div', { style: 'display:flex;gap:10px;flex-wrap:wrap;' }, [
h('div', { style: 'flex:2;min-width:150px;' }, feld('Datum', h('input', { type: 'date', value: rueckruf.date, style: S_INPUT, onInput: (e) => { rueckruf.date = e.target.value; } }))),
h('div', { style: 'flex:1;min-width:110px;' }, feld('Uhrzeit', h('input', { type: 'time', value: rueckruf.time, style: S_INPUT, onInput: (e) => { rueckruf.time = e.target.value; } }))),
]),
h('div', { style: 'display:flex;gap:8px;' }, [btn('Erinnerung setzen', () => planeRueckruf(k), 'primary'), btn('Abbrechen', () => { rueckruf.open = false; }, 'ghost')]),
]) : null;
const mailForm = mailMich.open ? h('div', { style: 'border:1px solid var(--border-normal);border-radius:10px;padding:14px;margin-top:10px;margin-bottom:12px;background:var(--background-subdued);' }, [
feld('Notiz an mich', h('textarea', { value: mailMich.note, rows: 3, placeholder: 'Was soll dir per Mail zugehen? (Eckdaten kommen automatisch mit)', style: S_INPUT + 'resize:vertical;', onInput: (e) => { mailMich.note = e.target.value; } })),
h('div', { style: 'display:flex;gap:8px;' }, [btn('Mail an mich senden', () => sendeMail(k), 'primary'), btn('Abbrechen', () => { mailMich.open = false; }, 'ghost')]),
]) : null;
const offene = aktionen.value.filter((a) => a.type === 'rueckruf' && a.status === 'offen');
const offeneListe = offene.length ? h('div', { style: 'margin-bottom:12px;display:flex;flex-direction:column;gap:6px;' }, offene.map((a) => h('div', {
style: 'display:flex;align-items:center;justify-content:space-between;gap:10px;border:1px solid var(--border-normal);border-radius:8px;padding:8px 12px;background:var(--background-page);',
}, [
h('div', { style: 'font-size:13px;' }, [
h('span', { style: 'font-weight:600;' }, '📞 Rückruf ' + (a.due_date ? new Date(a.due_date).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : '')),
a.note ? h('div', { style: 'color:var(--foreground-subdued);margin-top:2px;white-space:pre-wrap;' }, a.note) : null,
]),
btn('✓ erledigt', () => erledige(a, k), 'default'),
]))) : null;
const aktionenBlock = h('div', { style: 'margin-bottom:14px;' }, [
h('div', { style: LBL }, 'Aktionen'), aktionsLeiste, rueckrufForm, mailForm, offeneListe,
]);
return [navRow, h('div', { style: CARD }, [kopf, anruf, notizFeld, statusFeld, aktionenBlock, telFeld, mailFeld, namensFelder])];
}
return () => {
const inhalt = h('div', { style: 'max-width:760px;margin:0 auto;padding:20px 16px 60px;' }, [
// Aktions-Buttons: am Desktop nebeneinander (gleich breit), am Handy gestapelt in voller Breite.
h('style', {}, '.sk-akt{display:flex;gap:8px;flex-wrap:wrap;}.sk-akt>*{flex:1 1 auto;justify-content:center;}@media(max-width:600px){.sk-akt{flex-direction:column;}.sk-akt>*{width:100%;flex:none;}}'),
fehler.value ? h('div', { style: 'background:var(--danger-25,rgba(198,40,40,.12));color:var(--danger,#c62828);padding:10px 14px;border-radius:8px;margin-bottom:12px;font-size:14px;' }, fehler.value) : null,
meldung.value ? h('div', { style: 'background:var(--success-25,rgba(46,125,50,.15));color:var(--success,#2e7d32);padding:10px 14px;border-radius:8px;margin-bottom:12px;font-size:14px;' }, meldung.value) : null,
aktivId.value ? detailAnsicht() : listenAnsicht(),
]);
return h(resolveComponent('private-view'), { title: 'Kontakte' }, {
default: () => h('div', { style: 'min-height:100%;background:var(--background-subdued,#f1f4f9);' }, [inhalt]),
});
};
},
});
export default {
id: 'skrift-kontakte',
name: 'Kontakte',
icon: 'contacts',
routes: [{ path: '', component: Board }],
};

View File

@@ -0,0 +1,13 @@
{
"name": "directus-extension-skrift-kontakte",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Modul: Kontakte/Leads – handytaugliches Mini-CRM (Anrufen via GroundWire, Notizen, Durchblättern, CSV-Export).",
"directus:extension": {
"type": "module",
"path": "dist/index.js",
"source": "dist/index.js",
"host": "^11.0.0"
}
}

View File

@@ -0,0 +1,291 @@
/**
* Skrift – Mail-Hook
* ---------------------------------------------------------------------------
* Versendet Kunden-E-Mails auf Basis editierbarer Vorlagen (`email_templates`)
* mit gemeinsamer Signatur/Footer (`email_settings`) und optionalem AGB-Anhang.
*
* orders.items.create → Vorlage „order_created" (reiche Bestellbestätigung)
* orders.items.update → Vorlage mit dem Schlüssel des neuen Status
* (z. B. „bezahlt", „versendet"), falls vorhanden+aktiv
*
* Der Login-Code wird bewusst in skrift-auth versendet (dort entsteht er).
*/
import fs from 'node:fs';
import path from 'node:path';
const STATUS_LABEL = {
wartet_auf_zahlung: 'Wartet auf Zahlung', bezahlt: 'Bezahlt',
material_erwartet: 'Material erwartet', material_eingegangen: 'Material eingegangen',
in_queue: 'In Warteschlange', in_produktion: 'In Produktion', gedruckt: 'Gedruckt',
versendet: 'Versendet', abgeschlossen: 'Abgeschlossen', storniert: 'Storniert',
};
const ENVELOPE_LABEL = { dinlang: 'DIN Lang', c6: 'C6' };
const LABELING_LABEL = { empfaenger: 'Empfängeradresse', freitext: 'Individueller Text', keine: 'Keine' };
const UPLOAD_ROOT = process.env.STORAGE_LOCAL_ROOT || '/directus/uploads';
// Eingebaute Fallback-Vorlagen: greifen NUR, wenn in `email_templates` keine
// aktive Vorlage mit passendem Schlüssel existiert. So gehen Bestätigung und
// die kundenrelevanten Status-Mails auch ohne konfigurierte Vorlage sicher raus
// (analog zum Login-Fallback in skrift-auth). Interne Status (in_queue, gedruckt …)
// haben bewusst KEINEN Fallback → für die braucht es eine aktive Vorlage.
const FALLBACK_TEMPLATES = {
order_created: {
subject: 'Bestellbestätigung {{order_id}}',
body: `<p>Hallo {{name}},</p>
<p>vielen Dank für Ihre Bestellung <strong>{{order_id}}</strong>. Wir haben Ihren Auftrag erhalten und melden uns mit den nächsten Schritten.</p>`,
},
bezahlt: {
subject: 'Zahlung erhalten – Bestellung {{order_id}}',
body: `<p>Hallo {{name}},</p>
<p>Ihre Zahlung zur Bestellung <strong>{{order_id}}</strong> ist bei uns eingegangen. Wir starten nun mit der Bearbeitung.</p>`,
},
versendet: {
subject: 'Ihre Bestellung {{order_id}} ist unterwegs',
body: `<p>Hallo {{name}},</p>
<p>Ihre Bestellung <strong>{{order_id}}</strong> wurde versendet.</p>`,
},
abgeschlossen: {
subject: 'Bestellung {{order_id}} abgeschlossen',
body: `<p>Hallo {{name}},</p>
<p>Ihre Bestellung <strong>{{order_id}}</strong> ist abgeschlossen. Vielen Dank für Ihr Vertrauen!</p>`,
},
storniert: {
subject: 'Bestellung {{order_id}} storniert',
body: `<p>Hallo {{name}},</p>
<p>Ihre Bestellung <strong>{{order_id}}</strong> wurde storniert. Bei Fragen antworten Sie einfach auf diese E-Mail.</p>`,
},
};
const render = (str, vars) =>
String(str || '').replace(/\{\{(\w+)\}\}/g, (_, k) => (vars[k] != null ? String(vars[k]) : ''));
const eur = (n) => (Number(n) || 0).toFixed(2).replace('.', ',');
const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
// Antwortadresse für Kundenmails – echte, überwachte Adresse statt „noreply".
const REPLY_TO = 'hello@skrift.de';
/** Grobe Text-Alternative aus dem HTML. Reine HTML-Mails ohne Text-Teil kosten
* bei Spamfiltern (SpamAssassin) sichere Punkte – der Text-Part senkt die Wertung. */
const htmlZuText = (html) => String(html || '')
.replace(/<\s*br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|tr|h[1-6]|li|table)>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&').replace(/&lt;/gi, '<').replace(/&gt;/gi, '>')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
/** Formatiert einen Adress-Snapshot (billing/shipping) als HTML. */
function adresseHtml(a) {
if (!a || typeof a !== 'object') return '';
const zeilen = [];
if (a.company) zeilen.push(`<strong>${esc(a.company)}</strong>`);
zeilen.push(esc([a.first_name, a.last_name].filter(Boolean).join(' ')));
if (a.street) zeilen.push(esc(a.street));
zeilen.push(esc([a.zip, a.city].filter(Boolean).join(' ')));
if (a.country) zeilen.push(esc(a.country));
let html = zeilen.filter(Boolean).join('<br>');
if (a.email) html += `<br><br>E-Mail: ${esc(a.email)}`;
if (a.phone) html += `<br>Telefon: ${esc(a.phone)}`;
return html;
}
export default (register, { services, getSchema, logger }) => {
const { ItemsService, MailService } = services;
async function loadTemplate(key, schema) {
const rows = await new ItemsService('email_templates', { schema, accountability: null }).readByQuery({
filter: { key: { _eq: key }, enabled: { _eq: true } },
limit: 1, fields: ['subject', 'body', 'append_signature', 'attach_agb'],
});
return rows?.[0] || null;
}
async function loadSettings(schema) {
try {
return await new ItemsService('email_settings', { schema, accountability: null })
.readSingleton({ fields: ['signature', 'portal_url', 'company_footer', 'agb_file', 'sender_name', 'sender_email'] });
} catch { return {}; }
}
/** Konfigurierter Team-Empfänger (separat gelesen, damit ein evtl. noch nicht
* gebootstraptes Feld die Kundenmails nicht beeinträchtigt). */
async function teamEmpfaenger(schema) {
try {
const es = await new ItemsService('email_settings', { schema, accountability: null })
.readSingleton({ fields: ['team_email'] });
return (es?.team_email || '').trim();
} catch { return ''; }
}
/** Schlanke interne Benachrichtigung „Neue Bestellung …" (Empfänger konfigurierbar). */
async function teamNotify(orderId) {
try {
const schema = await getSchema();
const to = await teamEmpfaenger(schema);
if (!to) return;
const settings = await loadSettings(schema);
const order = await new ItemsService('orders', { schema, accountability: null })
.readOne(orderId, { fields: ['order_number', 'product.name', 'source'] });
if (!order) return;
// API-Aufträge NICHT einzeln melden – dafür gibt es die Tagesübersicht (18:00).
if (order.source === 'api') return;
const html = `<p>Neue Bestellung <strong>${order.order_number}</strong>${order.product?.name ? ` (${order.product.name})` : ''} – im Produktions-Board unter „Neu".</p>`;
const nachricht = {
to,
subject: `Neue Bestellung ${order.order_number}`,
html,
text: htmlZuText(html),
replyTo: REPLY_TO,
};
if (settings.sender_email) {
nachricht.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
}
await new MailService({ schema, accountability: null }).send(nachricht);
} catch (e) {
logger.warn(`[skrift-mailer] Team-Benachrichtigung: ${e.message}`);
}
}
/** AGB-PDF (aus email_settings.agb_file) als Nodemailer-Anhang, direkt von Platte. */
async function agbAnhang(schema, settings) {
if (!settings?.agb_file) return null;
try {
const file = await new ItemsService('directus_files', { schema, accountability: null })
.readOne(settings.agb_file, { fields: ['filename_disk', 'filename_download', 'type'] });
if (!file?.filename_disk) return null;
const buf = fs.readFileSync(path.join(UPLOAD_ROOT, file.filename_disk));
return { filename: file.filename_download || 'AGB.pdf', content: buf, contentType: file.type || 'application/pdf' };
} catch (e) {
logger.warn(`[skrift-mailer] AGB-Anhang übersprungen: ${e.message}`);
return null;
}
}
async function sendForOrder(orderId, key, extraVars = {}) {
const schema = await getSchema();
const tpl = await loadTemplate(key, schema) || FALLBACK_TEMPLATES[key];
if (!tpl) return; // weder aktive Vorlage noch Fallback → keine Mail
const order = await new ItemsService('orders', { schema, accountability: null }).readOne(orderId, {
fields: [
'order_number', 'customer_email', 'entries_count', 'shipping_type',
'needs_envelope', 'envelope_labeling', 'envelope_format',
'net_total', 'vat_amount', 'gross_total', 'billing', 'shipping_address',
'customer.first_name', 'customer.last_name', 'customer.company',
'product.name', 'product.input_mode', 'format.name',
],
});
if (!order?.customer_email) return;
// Datei-Produkte (Unterschriftenservice) erhalten KEINE Standard-Bestell-
// bestätigung – dafür geht eine eigene Vorlage aus dem Bestell-Endpunkt raus.
if (key === 'order_created' && order.product?.input_mode === 'datei') return;
const settings = await loadSettings(schema);
const name = [order.customer?.first_name, order.customer?.last_name].filter(Boolean).join(' ')
|| order.customer?.company || '';
const net = Number(order.net_total) || 0;
const vat = Number(order.vat_amount) || 0;
const mwstSatz = net > 0 ? Math.round((vat / net) * 100) : 19;
const kuvertBlock = (order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine')
? `<table role="presentation" width="100%" style="background:#fafafa;border-radius:6px;margin-top:12px;"><tr><td style="padding:12px 15px;">
<p style="margin:0 0 6px;font-weight:600;color:#1a365d;">Umschlag</p>
<p style="margin:0;font-size:14px;color:#666;">Format: ${ENVELOPE_LABEL[order.envelope_format] || '—'} · Beschriftung: ${LABELING_LABEL[order.envelope_labeling] || '—'}</p>
</td></tr></table>`
: '';
const liefer = adresseHtml(order.shipping_address);
const lieferBlock = liefer
? `<tr><td style="padding:16px 40px 4px;"><h2 style="margin:0 0 12px;font-size:18px;color:#1a365d;">Lieferadresse</h2><p style="margin:0;line-height:1.6;">${liefer}</p></td></tr>`
: '';
const vars = {
order_id: order.order_number, name, link: settings.portal_url || '',
product: esc(order.product?.name || ''), menge: order.entries_count ?? '',
format: esc(order.format?.name || ''),
versand: order.shipping_type === 'einzeln' ? 'Einzelversand durch Skrift' : 'Sammellieferung',
kuvert_block: kuvertBlock,
netto: eur(net), mwst_satz: mwstSatz, mwst_betrag: eur(vat), brutto: eur(order.gross_total), waehrung: 'EUR',
rechnungsadresse: adresseHtml(order.billing),
lieferadresse_block: lieferBlock,
footer: settings.company_footer || '',
...extraVars,
};
let html = render(tpl.body, vars);
if (tpl.append_signature !== false && settings.signature) html += render(settings.signature, vars);
const nachricht = { to: order.customer_email, subject: render(tpl.subject, vars), html, text: htmlZuText(html), replyTo: REPLY_TO };
// Absender aus den Einstellungen (z. B. „Skrift <noreply@skrift.de>").
if (settings.sender_email) {
nachricht.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
}
if (tpl.attach_agb) {
const anhang = await agbAnhang(schema, settings);
if (anhang) nachricht.attachments = [anhang];
}
await new MailService({ schema, accountability: null }).send(nachricht);
logger.info(`[skrift-mailer] „${key}" an ${order.customer_email} (${order.order_number}).`);
}
register.action('orders.items.create', async ({ key, payload }) => {
if (payload?.status === 'entwurf') return;
try { await sendForOrder(key, 'order_created'); }
catch (e) { logger.warn(`[skrift-mailer] order_created: ${e.message}`); }
// Schlanke interne Benachrichtigung „Neue Bestellung …" an das Team.
await teamNotify(key);
});
register.action('orders.items.update', async ({ keys, payload }) => {
if (!payload || payload.status === undefined) return;
const status = payload.status;
for (const id of keys || []) {
try { await sendForOrder(id, status, { status: STATUS_LABEL[status] || status }); }
catch (e) { logger.warn(`[skrift-mailer] status ${status}: ${e.message}`); }
}
});
// ── Tagesübersicht der API-Aufträge (statt Einzelmail je Auftrag) ───────────
// Einmal täglich (Default 18:00): pro API-Kunde eine Zusammenfassung der heute
// eingegangenen Aufträge, aufgeschlüsselt nach Versandtag.
const API_DIGEST_CRON = process.env.SKRIFT_API_DIGEST_CRON || '0 18 * * *';
register.schedule(API_DIGEST_CRON, async () => {
try {
const schema = await getSchema();
const to = await teamEmpfaenger(schema);
if (!to) return;
const settings = await loadSettings(schema);
const start = new Date(); start.setHours(0, 0, 0, 0);
const orders = await new ItemsService('orders', { schema, accountability: null }).readByQuery({
filter: { _and: [{ source: { _eq: 'api' } }, { date_created: { _gte: start.toISOString() } }] },
fields: ['order_number', 'shipping_day', 'api_client.id', 'api_client.name', 'product.name'],
limit: -1, sort: ['order_number'],
});
if (!orders || !orders.length) return;
const byClient = new Map();
for (const o of orders) {
const kid = o.api_client?.id ?? 'x';
let g = byClient.get(kid);
if (!g) { g = { name: o.api_client?.name || 'API-Kunde', orders: [] }; byClient.set(kid, g); }
g.orders.push(o);
}
for (const g of byClient.values()) {
const byDay = {};
for (const o of g.orders) { const d = o.shipping_day || 'ohne Versandtag'; byDay[d] = (byDay[d] || 0) + 1; }
const zeilen = Object.entries(byDay).map(([d, n]) => `<li>${esc(d)}: <strong>${n}</strong></li>`).join('');
const html = `<p>Heute über die API eingegangen von <strong>${esc(g.name)}</strong>: <strong>${g.orders.length}</strong> Auftrag/Aufträge.</p>`
+ `<p>Nach Versandtag:</p><ul>${zeilen}</ul>`
+ '<p>Im Produktions-Board unter „Neu" als Charge(n) gruppiert.</p>';
const nachricht = { to, subject: `API-Tagesübersicht ${g.name} – ${g.orders.length} neue`, html, text: htmlZuText(html), replyTo: REPLY_TO };
if (settings.sender_email) nachricht.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
try { await new MailService({ schema, accountability: null }).send(nachricht); }
catch (e) { logger.warn(`[skrift-mailer] API-Tagesmail ${g.name}: ${e.message}`); }
}
logger.info(`[skrift-mailer] API-Tagesübersicht verschickt: ${byClient.size} Kunde(n)`);
} catch (e) { logger.warn(`[skrift-mailer] API-Tageslauf: ${e.message}`); }
});
};

View File

@@ -0,0 +1,13 @@
{
"name": "directus-extension-skrift-mailer",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Versendet E-Mails (Bestellbestätigung + Statusänderungen) anhand editierbarer Vorlagen (email_templates + email_settings).",
"directus:extension": {
"type": "hook",
"path": "dist/index.js",
"source": "dist/index.js",
"host": "^11.0.0"
}
}

View File

@@ -0,0 +1,720 @@
/**
* Skrift – Modul „Neuer Auftrag"
* ===========================================================================
* Eigenständige, manuelle Auftragsanlage im Admin (eigenes Modul, kein Teil des
* Produktions-Boards). Nutzt ausschließlich die zentralen Backend-Endpunkte:
* POST /skrift-orders/preview – Vorschau (Admin ohne Kontingent)
* POST /skrift-orders/manual-order – Auftrag intern anlegen (keine Zahlung)
* Der Auftrag erscheint danach als „eingegangen" im Produktions-Board.
*
* Von Hand als ESM (kein Build-Schritt) – Vue + SDK stellt Directus bereit.
*/
import { defineComponent, ref, reactive, onMounted, h, resolveComponent, Teleport } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const ACCENT = '#1E50A5';
// Weißes, randloses Eingabefeld – hebt sich durch Kontrast + leichten Schatten
// vom hellblauen Bereich ab (kein Rahmen).
const WFELD = 'background:#fff;border:none;border-radius:8px;padding:9px 12px;font:inherit;font-size:14px;color:#1A1A1A;outline:none;box-shadow:0 1px 2px rgba(0,0,0,.08);';
const WSELECT = WFELD + 'cursor:pointer;';
// Hellblauer Bereich (kein Rahmen).
const BEREICH = 'background:rgba(30,80,165,.06);border-radius:12px;padding:18px 20px;';
const STANDARD_PLATZHALTER = ['Anrede', 'Vorname', 'Nachname', 'Strasse', 'PLZ', 'Ort', 'Land', 'Briefnummer'];
function btn(label, onClick, kind) {
const base = 'cursor:pointer;border-radius:8px;padding:8px 16px;font:inherit;font-size:14px;border:none;';
const style = kind === 'primary' ? base + `background:${ACCENT};color:#fff;box-shadow:0 1px 3px rgba(0,0,0,.15);`
: base + `background:#fff;color:${ACCENT};box-shadow:0 1px 2px rgba(0,0,0,.1);`;
return h('button', { type: 'button', onClick, style }, label);
}
function subdued(t, extra) { return h('div', { style: `color:var(--foreground-subdued);font-size:13px;${extra || ''}` }, t); }
function segToggle(value, opts, onSel) {
return h('div', { style: 'display:flex;width:100%;border-radius:8px;overflow:hidden;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.08);' },
opts.map((o) => h('button', {
type: 'button', onClick: () => onSel(o.key),
style: `flex:1;padding:10px 8px;font:inherit;font-size:13px;text-align:center;cursor:pointer;border:none;`
+ (value === o.key ? `background:${ACCENT};color:#fff;font-weight:700;` : `background:transparent;color:${ACCENT};`),
}, o.label)));
}
function bereichKopf(txt) { return h('div', { style: `font-size:15px;font-weight:800;color:${ACCENT};margin-bottom:14px;` }, txt); }
function label(txt) { return h('label', { style: 'display:block;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);margin-bottom:4px;' }, txt); }
const NeuAuftrag = defineComponent({
setup() {
const api = useApi();
const produkte = ref([]);
const formate = ref([]);
const verbotenRe = ref(null);
const musterTexte = ref({ text_du: '', text_sie: '' });
// Frei anlegbare Mustertexte (Collection muster_texts). Fallback: Du/Sie aus muster_settings.
const musterListe = ref([]);
// Vorhandene Kundensignaturen (für „Unterschrift auf den Brief setzen").
const signaturListe = ref([]);
const geladen = ref(false);
// Aktives Unterwerkzeug: 'neu' (manueller Druck) oder 'signatur' (Unterschriftenservice).
const ansicht = ref('neu');
// Unterschriftenservice: Auftrag laden → Dokument + Kundensignaturen wählen →
// Vorschau (Marker + Offsets) → A4-SVGs herunterladen.
const sig = reactive({
modus: 'auftrag', // 'auftrag' (aus Auftrag laden) oder 'manuell' (direkt hochladen)
orderNr: '', order: null, docs: [], docId: null, docName: '', signaturen: [],
laden: false, pdf: '', msg: '', err: '',
});
// Datei nach Directus hochladen (liefert die Datei-ID).
async function sigUpload(file) {
const fd = new FormData();
fd.append('file', file);
const res = await api.post('/files', fd);
return res.data?.data?.id;
}
const leereZeile = () => ({ free_text: '', placeholders: {} });
const neu = reactive({
product: '', format: '', font: 'tilda',
// Relative Schriftgröße in % (getrennt Brief/Kuvert); 100 = Standard.
fontSizeLetter: 100, fontSizeEnvelope: 100,
// Unterschrift auf dem Brief ([[signatur1]]): SVG-Inhalt + Feinjustierung.
sigSvg: '', sigLabel: '', sigX: 0, sigY: 0, sigScale: 100,
// Mehrseitiges Schriftstück (Auto-Fluss über mehrere A4-Seiten).
mehrseitig: false, seitenInfo: '',
// Kuvert-Adresse je Empfänger: '5zeilen' (5 Zeilen) oder 'individuell' (einzeilig).
adressModus: '5zeilen', kuverts: false, realistic: true, customer_email: '', text: '', rows: [leereZeile()],
importOpen: false, importText: '',
docs: [], previewSession: null, pLaedt: false, msg: '', err: '', hinweis: '', lb: '',
});
// Kuverts nur generieren, wenn angehakt (Beschriftung aus der Empfängeradresse).
const kuvertPayload = () => (neu.kuverts
? { needs_envelope: true, envelope_labeling: 'empfaenger', envelope_text: '' }
: { needs_envelope: false, envelope_labeling: 'keine', envelope_text: '' });
async function laden() {
try {
const [p, f, ps, mt, mtx, sg] = await Promise.all([
api.get('/items/products', { params: { limit: -1, filter: { active: { _eq: true }, type: { _in: ['letter', 'postcard'] } }, fields: 'key,name,type' } }),
api.get('/items/formats', { params: { limit: -1, filter: { active: { _eq: true } }, fields: 'key,name,product.key' } }),
api.get('/items/pricing_settings', { params: { fields: 'char_whitelist' } }).catch(() => ({ data: { data: {} } })),
api.get('/items/muster_settings', { params: { fields: 'text_du,text_sie' } }).catch(() => ({ data: { data: {} } })),
api.get('/items/muster_texts', { params: { limit: -1, filter: { active: { _eq: true } }, sort: 'sort', fields: 'id,label,text' } }).catch(() => ({ data: { data: [] } })),
api.get('/items/signatures', { params: { limit: -1, sort: '-date_created', fields: 'id,label,file,customer.first_name,customer.last_name' } }).catch(() => ({ data: { data: [] } })),
]);
produkte.value = p.data?.data || [];
formate.value = f.data?.data || [];
musterTexte.value = mt.data?.data || { text_du: '', text_sie: '' };
musterListe.value = mtx.data?.data || [];
signaturListe.value = sg.data?.data || [];
const wl = ps.data?.data?.char_whitelist;
// Zeilenumbrüche immer erlauben (Brieftext ist mehrzeilig). Einzeilige
// Felder entfernen \n/\r ohnehin separat, daher hier gefahrlos. Tabs (\t)
// sind erlaubt, damit man z.B. das Datum an den rechten Rand rücken kann.
try { verbotenRe.value = wl ? new RegExp(`[^${wl}\\n\\r\\t]`, 'g') : null; } catch { verbotenRe.value = null; }
neu.product = produkte.value[0]?.key || 'briefe';
neu.format = neuFormate()[0]?.key || '';
} catch (e) {
neu.err = e?.response?.data?.errors?.[0]?.message || e?.message || 'Laden fehlgeschlagen.';
} finally { geladen.value = true; }
}
onMounted(laden);
const saeubere = (v) => {
if (!verbotenRe.value) return v;
const treffer = String(v).match(verbotenRe.value);
if (!treffer || !treffer.length) return v;
const einmalig = [...new Set(treffer)].map((c) => (c === ' ' ? '␣' : c)).join(' ');
neu.hinweis = `Zeichen ${einmalig} ist nicht erlaubt und wurde entfernt.`;
setTimeout(() => { if (neu.hinweis.includes(einmalig)) neu.hinweis = ''; }, 4000);
return String(v).replace(verbotenRe.value, '');
};
// Alle aktiven Formate zur Auswahl (nicht aufs Produkt eingeschränkt) –
// so lassen sich auch freie/eigene Formate direkt wählen.
const neuFormate = () => formate.value;
// Wie viele Adresszeilen je Empfänger: 5 („5 Zeilen") bzw. 1 („Individueller Text").
const zeilenAnzahl = () => (neu.adressModus === 'individuell' ? 1 : 5);
const freitext = (e, z) => (String(e.free_text || '').split('\n')[z] ?? '');
const setFreitext = (i, z, v) => {
const max = zeilenAnzahl();
const zeilen = String(neu.rows[i].free_text || '').split('\n');
while (zeilen.length < max) zeilen.push('');
zeilen[z] = saeubere(String(v).replace(/[\r\n]/g, ''));
neu.rows[i].free_text = zeilen.slice(0, max).join('\n').replace(/\n+$/, '');
neu.previewSession = null;
};
// Individuelle Platzhalter je Empfänger (z. B. [[Gutschein]]).
const setPlatzhalter = (i, name, v) => {
neu.rows[i].placeholders = { ...(neu.rows[i].placeholders || {}), [name]: saeubere(v) };
neu.previewSession = null;
};
// Spalten-Einfügen: eine ganze Excel-Spalte (Strg+V) in EINE Zelle einfügen.
// Zeilenumbrüche trennen die Empfänger; Kommata bleiben Teil des Werts (kein
// Trennzeichen). Legt automatisch so viele Empfänger an wie eingefügte Zeilen
// und füllt die Spalte ab der aktuellen Zeile nach unten.
// Rückgabe true = mehrzeilig eingefügt (Default-Paste unterdrücken).
const spalteEinfuegen = (startI, apply, text) => {
const werte = String(text || '').replace(/\r\n?/g, '\n').split('\n');
while (werte.length > 1 && werte[werte.length - 1].trim() === '') werte.pop();
if (werte.length <= 1) return false; // Einzelwert → normales Einfügen zulassen
while (neu.rows.length < startI + werte.length) neu.rows.push(leereZeile());
werte.forEach((w, k) => apply(startI + k, w));
return true;
};
// Platzhalter aus dem Brieftext ziehen (ohne den automatischen [[Briefnummer]]).
const customPlaceholders = () => {
const set = new Set();
for (const m of String(neu.text || '').matchAll(/\[\[([^\]]+)\]\]/g)) {
const name = m[1].trim();
if (name && name.toLowerCase() !== 'briefnummer') set.add(name);
}
return [...set];
};
const addZeile = () => neu.rows.push(leereZeile());
const delZeile = (i) => { neu.rows.splice(i, 1); if (!neu.rows.length) neu.rows.push(leereZeile()); };
// Empfänger zählt, wenn eine Adresszeile ODER ein Platzhalterwert gefüllt ist.
const entries = () => neu.rows.filter((e) => String(e.free_text || '').trim()
|| (e.placeholders && Object.values(e.placeholders).some((v) => String(v || '').trim())));
// Aus Excel/CSV eingefügte Zeilen übernehmen. Erst die individuellen
// Platzhalter-Spalten, dann die Adresszeilen (Tab/Semikolon/Komma-getrennt).
function importUebernehmen() {
const ph = customPlaceholders();
const max = zeilenAnzahl();
const zeilen = String(neu.importText || '').split(/\r?\n/).map((z) => z.trim()).filter(Boolean);
if (!zeilen.length) { neu.importOpen = false; return; }
const rows = zeilen.map((z) => {
const zellen = z.split(/\t|;|,/).map((c) => saeubere(c.trim()));
const row = leereZeile();
ph.forEach((name, i) => { row.placeholders = { ...row.placeholders, [name]: zellen[i] || '' }; });
row.free_text = zellen.slice(ph.length, ph.length + max).join('\n');
return row;
});
neu.rows = rows;
neu.importText = ''; neu.importOpen = false; neu.previewSession = null;
}
// ── Unterschrift auf dem Brief ([[signatur1]]) ────────────────────────────
// Sorgt dafür, dass der Platzhalter im Text steht (sonst rendert keine Signatur).
function sigEinfuegen() {
if (!neu.text.includes('[[signatur1]]')) neu.text = `${neu.text.trimEnd()}\n[[signatur1]]`;
neu.previewSession = null;
}
async function sigWaehlen(id) {
neu.sigSvg = ''; neu.sigLabel = '';
if (!id) { neu.previewSession = null; return; }
const s = signaturListe.value.find((x) => String(x.id) === String(id));
if (!s?.file) { neu.err = 'Diese Signatur hat keine SVG-Datei.'; return; }
try {
const res = await api.get(`/assets/${s.file}`, { responseType: 'text' });
neu.sigSvg = typeof res.data === 'string' ? res.data : String(res.data || '');
neu.sigLabel = s.label || 'Unterschrift';
sigEinfuegen();
} catch { neu.err = 'Signatur konnte nicht geladen werden.'; }
}
function sigDatei(e) {
const file = e.target.files && e.target.files[0];
if (!file) return;
const r = new FileReader();
r.onload = () => { neu.sigSvg = String(r.result || ''); neu.sigLabel = file.name; sigEinfuegen(); };
r.readAsText(file);
}
function sigEntfernen() {
neu.sigSvg = ''; neu.sigLabel = '';
neu.text = neu.text.replace(/\n?\[\[signatur1\]\]/g, '');
neu.previewSession = null;
}
// Signatur-Anteil der Payloads (Vorschau + Anlage). „brief"-Präfix, weil es
// bereits ein sigPayload() für den Unterschriften-Service (PDF) gibt.
const briefSigPayload = () => (neu.sigSvg
? { signatures: { signatur1: neu.sigSvg },
signature_offsets: { signatur1: { x: Number(neu.sigX) || 0, y: Number(neu.sigY) || 0, scale: (Number(neu.sigScale) || 100) / 100 } } }
: {});
async function preview() {
// Vorschau ohne Empfänger erlaubt – das Backend nutzt dann eine Blankozeile.
if (!neu.text.trim()) { neu.err = 'Bitte zuerst einen Text eingeben.'; return; }
neu.pLaedt = true; neu.err = ''; neu.msg = '';
try {
const kv = kuvertPayload();
const res = await api.post('/skrift-orders/preview', {
text: neu.text, font: neu.font, format: neu.format, realistic: neu.realistic,
envelope_labeling: kv.envelope_labeling, envelope_text: kv.envelope_text,
font_scale_letter: neu.fontSizeLetter / 100, font_scale_envelope: neu.fontSizeEnvelope / 100,
multi_page: neu.mehrseitig,
...briefSigPayload(),
entries: entries().slice(0, 3),
});
const d = res.data || {};
if (d.has_overflow) {
// Zeigen, wie viele Zeilen zu lang (aus dem ersten überlaufenden Schriftstück).
const of = (d.overflow || [])[0];
const zuViel = of ? Math.max(1, (Number(of.lineCount) || 0) - (Number(of.lineLimit) || 0)) : 0;
neu.err = of
? `Text ist ${zuViel} Zeile${zuViel === 1 ? '' : 'n'} zu lang – ${of.lineCount} statt max. ${of.lineLimit} Zeilen. Bitte kürzen.`
: 'Text ist zu lang für das Format – bitte kürzen.';
neu.docs = []; neu.previewSession = null;
} else { neu.docs = d.docs || d.previews || []; neu.previewSession = d.session || d.sessionId || null; }
} catch (e) { neu.err = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Vorschau fehlgeschlagen.'; }
finally { neu.pLaedt = false; }
}
// Seiten je Schriftstück berechnen (mehrseitig) und nach Seitenzahl gruppieren:
// „2 Seiten: 1, 3, 4" · „3 Seiten: 2, 6, 7" …
async function seitenBerechnen() {
if (!neu.text.trim()) { neu.err = 'Bitte zuerst einen Text eingeben.'; return; }
neu.seitenInfo = 'Berechne …';
try {
const res = await api.post('/skrift-orders/pagecount', {
text: neu.text, format: neu.format,
font_scale_letter: neu.fontSizeLetter / 100,
entries: entries(),
});
const counts = res.data?.counts || [];
const gruppen = new Map();
counts.forEach((c) => { const g = gruppen.get(c.pages) || []; g.push(c.index); gruppen.set(c.pages, g); });
const zeilen = [...gruppen.keys()].sort((a, b) => a - b)
.map((p) => `${p} Seite${p === 1 ? '' : 'n'}: ${gruppen.get(p).join(', ')}`);
neu.seitenInfo = zeilen.join('\n') || 'Keine Schriftstücke.';
} catch (e) {
neu.seitenInfo = '';
neu.err = e?.response?.data?.error || e?.message || 'Seitenzählung fehlgeschlagen.';
}
}
async function absenden() {
// Empfänger sind optional – ohne Empfänger genau ein Schriftstück (Blankozeile).
const ent = entries();
const payloadEntries = ent.length ? ent : [leereZeile()];
neu.err = ''; neu.msg = 'Wird angelegt …';
try {
const kv = kuvertPayload();
const res = await api.post('/skrift-orders/manual-order', {
product: neu.product, format: neu.format, font: neu.font,
realistic: neu.realistic,
font_scale_letter: neu.fontSizeLetter / 100, font_scale_envelope: neu.fontSizeEnvelope / 100,
multi_page: neu.mehrseitig,
...briefSigPayload(),
needs_envelope: kv.needs_envelope, envelope_labeling: kv.envelope_labeling, envelope_text: kv.envelope_text,
shipping_type: 'sammel',
customer_email: neu.customer_email.trim() || undefined,
text_template: neu.text, preview_session: neu.previewSession || undefined,
entries: payloadEntries,
});
const nr = res.data?.order_number || '';
// Felder bewusst NICHT zurücksetzen – der letzte Auftrag bleibt stehen,
// damit er sich als Vorlage für den nächsten leicht anpassen lässt.
neu.msg = `✓ Auftrag ${nr} angelegt – liegt im Produktions-Board unter „Neu". Felder bleiben stehen; mit „Formular leeren" zurücksetzen.`;
} catch (e) { neu.err = e?.response?.data?.error || e?.message || 'Anlegen fehlgeschlagen.'; neu.msg = ''; }
}
function zuruecksetzen() {
Object.assign(neu, {
product: produkte.value[0]?.key || 'briefe', format: neuFormate()[0]?.key || '', font: 'tilda',
fontSizeLetter: 100, fontSizeEnvelope: 100,
sigSvg: '', sigLabel: '', sigX: 0, sigY: 0, sigScale: 100, mehrseitig: false, seitenInfo: '',
adressModus: '5zeilen', kuverts: false, realistic: true, customer_email: '', text: '', rows: [leereZeile()],
importOpen: false, importText: '',
docs: [], previewSession: null, pLaedt: false, msg: '', err: '', hinweis: '', lb: '',
});
}
const musterEinfuegen = (wert) => {
// Freie Mustertexte (per ID) bevorzugt; sonst Fallback Du/Sie.
let t = '';
if (wert === 'du' || wert === 'sie') t = wert === 'du' ? musterTexte.value.text_du : musterTexte.value.text_sie;
else t = (musterListe.value.find((m) => String(m.id) === String(wert)) || {}).text || '';
if (!t) return;
neu.text = neu.text.trim() ? `${neu.text}\n\n${t}` : t;
neu.previewSession = null;
};
// Weißes Dropdown / Eingabefeld.
const sel = (val, opts, on, extra) => h('select', { style: WSELECT + 'width:100%;' + (extra || ''), value: val, onChange: on },
opts.map((o) => h('option', { value: o.v, selected: String(val) === String(o.v) }, o.t)));
const svgUri = (svg) => { try { return 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg))); } catch { return null; } };
// Vorschau-Optik „Kugelschreiber": NUR für die Anzeige die schwarzen Striche
// in Kuli-Blau umfärben. Die generierten/gecachten SVGs bleiben schwarz –
// die Produktion (die die gezeigten Dokumente 1:1 übernimmt) ist unberührt.
const BALLPOINT = '#1a2a6c';
const inkify = (svg) => (typeof svg === 'string' ? svg.replace(/stroke="#0{3,6}"/gi, `stroke="${BALLPOINT}"`) : svg);
// Vorschau als PNG speichern (wie in der App); Fallback: SVG.
function bildSpeichern(d) {
const svg = d && typeof d.svg === 'string' ? inkify(d.svg) : null;
if (!svg) return;
const name = (d?.art === 'umschlag' ? 'kuvert' : 'schriftstueck') + (d?.page ? `_s${d.page}` : '');
const uri = svgUri(svg);
const img = new Image();
img.onload = () => {
const scale = 2;
const cv = document.createElement('canvas');
cv.width = (img.naturalWidth || 800) * scale; cv.height = (img.naturalHeight || 1130) * scale;
const ctx = cv.getContext('2d');
ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, cv.width, cv.height);
ctx.drawImage(img, 0, 0, cv.width, cv.height);
cv.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = name + '.png';
document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 4000);
}, 'image/png');
};
img.onerror = () => {
const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
const a = document.createElement('a'); a.href = url; a.download = name + '.svg';
document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 4000);
};
img.src = uri;
}
const docBild = (d) => {
const svg = d && typeof d.svg === 'string' ? d.svg : null;
const src = svg ? svgUri(inkify(svg)) : (typeof d === 'string' ? d : (d?.dataUrl || d?.url || d?.image || null));
const seite = (d?.page && d?.pageCount) ? ` · Seite ${d.page}/${d.pageCount}` : '';
const txt = (d?.art === 'umschlag' ? '✉ Kuvert' : '📄 Schriftstück') + seite;
return h('div', { style: 'display:flex;flex-direction:column;align-items:center;gap:4px;' }, [
src ? h('img', { src, onClick: () => { neu.lb = src; }, style: 'width:100%;max-height:260px;object-fit:contain;border-radius:6px;background:#fff;cursor:zoom-in;box-shadow:0 1px 3px rgba(0,0,0,.12);' })
: h('div', { style: 'width:100%;height:200px;border-radius:6px;display:flex;align-items:center;justify-content:center;background:#fff;color:var(--foreground-subdued);' }, 'Dok.'),
h('div', { style: 'display:flex;align-items:center;gap:8px;' }, [
h('span', { style: 'font-size:11px;color:var(--foreground-subdued);' }, txt),
svg ? h('button', { type: 'button', onClick: () => bildSpeichern(d), style: 'cursor:pointer;border:1px solid var(--border-normal);background:var(--background-page);border-radius:6px;padding:2px 8px;font:inherit;font-size:11px;color:var(--foreground-normal);' }, '💾 Speichern') : null,
]),
]);
};
// Weißes, einzeiliges Zellenfeld (feste Breite → Zeile scrollt bei Bedarf).
const feld = (lbl, val, on, ph, onPaste) => h('div', { style: 'display:flex;flex-direction:column;gap:3px;flex:1 1 0;min-width:90px;' }, [
h('label', { style: 'font-size:10px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);' }, lbl),
h('input', { value: val, placeholder: ph || '', style: WFELD + 'width:100%;padding:7px 9px;font-size:13px;', onInput: on, onPaste }),
]);
function formular() {
// Empfängerzeile: erst individuelle Platzhalter, dann die Adresszeilen.
const zeile = (e, i) => [
...customPlaceholders().map((name) => feld(name, e.placeholders?.[name] || '', (ev) => setPlatzhalter(i, name, ev.target.value), '',
(ev) => { if (spalteEinfuegen(i, (ri, v) => setPlatzhalter(ri, name, v), ev.clipboardData && ev.clipboardData.getData('text'))) ev.preventDefault(); })),
...Array.from({ length: zeilenAnzahl() }, (_, z) => feld('Zeile ' + (z + 1), freitext(e, z), (ev) => setFreitext(i, z, ev.target.value), '',
(ev) => { if (spalteEinfuegen(i, (ri, v) => setFreitext(ri, z, v), ev.clipboardData && ev.clipboardData.getData('text'))) ev.preventDefault(); })),
];
// Bereich 1 – Produkt
const b1 = h('div', { style: BEREICH }, [
bereichKopf('📄 Produkt'),
h('div', { style: 'display:grid;grid-template-columns:repeat(3,1fr);gap:12px;' }, [
h('div', {}, [label('Produkt'), sel(neu.product, produkte.value.map((p) => ({ v: p.key, t: p.name })), (e) => { neu.product = e.target.value; neu.format = neuFormate()[0]?.key || ''; neu.previewSession = null; })]),
h('div', {}, [label('Format'), sel(neu.format, neuFormate().map((f) => ({ v: f.key, t: f.name })), (e) => { neu.format = e.target.value; neu.previewSession = null; })]),
h('div', {}, [label('Schrift'), sel(neu.font, [['tilda', 'Tilda'], ['alva', 'Alva'], ['ellie', 'Ellie']].map(([v, t]) => ({ v, t })), (e) => { neu.font = e.target.value; neu.previewSession = null; })]),
]),
h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px;' }, [
h('div', {}, [label('Schriftgröße Brief (%)'),
// Frei tippbar; erst beim Verlassen des Feldes auf 80–150 begrenzen.
h('input', { type: 'number', min: 80, max: 150, step: 5, value: neu.fontSizeLetter, style: WFELD + 'width:100%;',
onInput: (e) => { neu.fontSizeLetter = e.target.value; neu.previewSession = null; },
onBlur: (e) => { const n = Math.round(Number(e.target.value)); neu.fontSizeLetter = (Number.isFinite(n) && n) ? Math.max(80, Math.min(150, n)) : 100; } })]),
h('div', {}, [label('Schriftgröße Kuvert (%)'),
h('input', { type: 'number', min: 80, max: 150, step: 5, value: neu.fontSizeEnvelope, style: WFELD + 'width:100%;',
onInput: (e) => { neu.fontSizeEnvelope = e.target.value; neu.previewSession = null; },
onBlur: (e) => { const n = Math.round(Number(e.target.value)); neu.fontSizeEnvelope = (Number.isFinite(n) && n) ? Math.max(80, Math.min(150, n)) : 100; } })]),
]),
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;margin-top:12px;cursor:pointer;' }, [
h('input', { type: 'checkbox', checked: neu.realistic, onChange: (e) => { neu.realistic = e.target.checked; neu.previewSession = null; }, style: 'width:16px;height:16px;' }),
'Natürliche Handschrift (leichte Variation)',
]),
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;margin-top:8px;cursor:pointer;' }, [
h('input', { type: 'checkbox', checked: neu.mehrseitig, onChange: (e) => { neu.mehrseitig = e.target.checked; neu.previewSession = null; }, style: 'width:16px;height:16px;' }),
'Mehrseitiges Schriftstück (Text fließt automatisch über mehrere A4-Seiten)',
]),
]);
// Bereich 2 – Kuvert (Empfängertabelle)
const b2 = h('div', { style: BEREICH }, [
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px;' }, [
bereichKopf('✉ Kuvert'),
h('div', { style: 'width:300px;' }, [segToggle(neu.adressModus, [{ key: '5zeilen', label: '5 Zeilen' }, { key: 'individuell', label: 'Individueller Text' }], (k) => { neu.adressModus = k; neu.previewSession = null; })]),
]),
h('div', { style: 'display:flex;flex-direction:column;gap:8px;max-height:34vh;overflow:auto;' },
neu.rows.map((e, i) => h('div', { style: 'display:flex;gap:8px;align-items:center;' }, [
h('span', { style: `min-width:22px;font-weight:700;color:${ACCENT};` }, `#${i + 1}`),
h('div', { style: 'flex:1;min-width:0;display:flex;gap:6px;flex-wrap:nowrap;overflow-x:auto;padding-bottom:2px;' }, zeile(e, i)),
h('button', { type: 'button', onClick: () => delZeile(i), style: 'cursor:pointer;border:none;background:transparent;color:var(--danger,#c62828);font-size:18px;padding:2px 6px;' }, '×'),
]))),
h('div', { style: 'margin-top:12px;display:grid;grid-template-columns:1fr 1fr;gap:12px;' }, [
btn('+ Empfänger', addZeile),
btn(neu.importOpen ? 'Import schließen' : '⇪ Aus Excel einfügen', () => { neu.importOpen = !neu.importOpen; }),
]),
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer;margin-top:12px;' }, [
h('input', { type: 'checkbox', checked: neu.kuverts, onChange: (e) => { neu.kuverts = e.target.checked; neu.previewSession = null; }, style: 'width:16px;height:16px;' }),
'Kuverts generieren',
]),
neu.importOpen ? h('div', { style: 'margin-top:12px;' }, [
subdued('Aus Excel kopieren und einfügen – eine Zeile pro Empfänger, Spalten mit Tab/Semikolon/Komma. '
+ 'Reihenfolge: erst individuelle Platzhalter, dann ' + (neu.adressModus === 'individuell' ? 'die eine Adresszeile.' : 'Zeile 1–5.'), 'margin-bottom:6px;'),
h('textarea', { value: neu.importText, rows: 5, style: WFELD + 'width:100%;font-family:var(--family-monospace,monospace);', onInput: (e) => { neu.importText = e.target.value; } }),
h('div', { style: 'margin-top:6px;' }, [btn('Übernehmen', importUebernehmen, 'primary')]),
]) : null,
]);
// Bereich 3 – Text (Mustertext-Dropdown direkt darüber)
const b3 = h('div', { style: BEREICH }, [
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px;' }, [
bereichKopf('✍ Text'),
h('select', { style: WSELECT + 'font-size:13px;padding:7px 10px;width:auto;', onChange: (e) => { if (e.target.value) { musterEinfuegen(e.target.value); e.target.value = ''; } } },
[h('option', { value: '' }, 'Mustertext einfügen …')].concat(
musterListe.value.length
? musterListe.value.map((m) => h('option', { value: m.id }, m.label || `Text ${m.id}`))
: [h('option', { value: 'sie' }, 'Sie-Form'), h('option', { value: 'du' }, 'Du-Form')],
)),
]),
label(`Brieftext · ${neu.text.length} Zeichen · [[Platzhalter]] erlaubt`),
h('textarea', { value: neu.text, rows: 8, style: WFELD + 'width:100%;font-family:var(--family-monospace,monospace);', onInput: (e) => { neu.text = saeubere(e.target.value); neu.previewSession = null; }, onKeydown: (e) => {
// Tab-Taste fügt einen echten Tabulator ein (statt den Fokus zu verschieben).
if (e.key !== 'Tab' || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
e.preventDefault();
const ta = e.target, s = ta.selectionStart, en = ta.selectionEnd;
neu.text = saeubere(neu.text.slice(0, s) + '\t' + neu.text.slice(en));
neu.previewSession = null;
requestAnimationFrame(() => { try { ta.selectionStart = ta.selectionEnd = s + 1; } catch {} });
} }),
// ── Unterschrift auf den Brief ─────────────────────────────────────
h('div', { style: 'margin-top:16px;border-top:1px solid rgba(30,80,165,.15);padding-top:14px;' }, [
label('Unterschrift auf den Brief (optional) · Platzhalter [[signatur1]]'),
h('div', { style: 'display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center;' }, [
h('select', { style: WSELECT + 'width:100%;', onChange: (e) => sigWaehlen(e.target.value) },
[h('option', { value: '' }, 'Vorhandene Unterschrift wählen …')].concat(
signaturListe.value.map((s) => {
const kunde = [s.customer?.first_name, s.customer?.last_name].filter(Boolean).join(' ');
return h('option', { value: s.id }, (s.label || `Signatur ${s.id}`) + (kunde ? ` · ${kunde}` : ''));
}))),
h('label', { style: 'cursor:pointer;border-radius:8px;padding:8px 16px;font:inherit;font-size:14px;border:none;background:#fff;color:' + ACCENT + ';box-shadow:0 1px 2px rgba(0,0,0,.1);white-space:nowrap;' }, [
'⇪ SVG hochladen',
h('input', { type: 'file', accept: '.svg,image/svg+xml', style: 'display:none;', onChange: sigDatei }),
]),
]),
neu.sigSvg ? h('div', { style: 'margin-top:12px;' }, [
h('div', { style: 'display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;' }, [
h('span', { style: `color:${ACCENT};font-size:13px;font-weight:600;` }, `✓ ${neu.sigLabel} – als [[signatur1]] im Text`),
btn('Entfernen', sigEntfernen),
]),
h('div', { style: 'display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;' }, [
h('div', {}, [label('Versatz X (mm)'), h('input', { type: 'number', step: 1, value: neu.sigX, style: WFELD + 'width:100%;', onInput: (e) => { neu.sigX = e.target.value; neu.previewSession = null; } })]),
h('div', {}, [label('Versatz Y (mm)'), h('input', { type: 'number', step: 1, value: neu.sigY, style: WFELD + 'width:100%;', onInput: (e) => { neu.sigY = e.target.value; neu.previewSession = null; } })]),
h('div', {}, [label('Größe (%)'), h('input', { type: 'number', step: 5, value: neu.sigScale, style: WFELD + 'width:100%;', onInput: (e) => { neu.sigScale = e.target.value; neu.previewSession = null; } })]),
]),
subdued('Positiv X = nach rechts, positiv Y = nach unten. Nach dem Anpassen „Aktualisieren" in der Vorschau klicken.', 'margin-top:8px;'),
]) : null,
]),
]);
// Bereich 4 – Kunde
const b4 = h('div', { style: BEREICH }, [
bereichKopf('👤 Kunde'),
h('div', { style: 'max-width:420px;' }, [
label('Kunde verknüpfen (E-Mail, optional)'),
h('input', { type: 'email', value: neu.customer_email, placeholder: 'kunde@example.de', style: WFELD + 'width:100%;', onInput: (e) => { neu.customer_email = e.target.value; } }),
]),
]);
const aktionen = h('div', { class: 'sk-btnrow', style: 'display:flex;gap:8px;flex-wrap:wrap;' }, [
neu.hinweis ? h('div', { style: `color:var(--danger,#c62828);font-size:13px;width:100%;` }, neu.hinweis) : null,
neu.err ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;width:100%;' }, neu.err) : null,
neu.msg ? h('div', { style: 'width:100%;' }, subdued(neu.msg)) : null,
btn(neu.pLaedt ? 'Vorschau …' : '👁 Vorschau', preview),
btn('In Druck geben', absenden, 'primary'),
btn('Formular leeren', zuruecksetzen),
]);
const links = h('div', { style: 'flex:1;min-width:0;display:flex;flex-direction:column;gap:16px;' }, [b1, b2, b3, b4, aktionen]);
// Vorschau rechts daneben (eigener hellblauer Bereich).
const rechts = h('div', { style: BEREICH + 'flex:0 0 340px;position:sticky;top:20px;align-self:flex-start;' }, [
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;' }, [
bereichKopf('👁 Vorschau'),
btn(neu.pLaedt ? '…' : 'Aktualisieren', preview),
]),
neu.pLaedt ? subdued('Wird erzeugt …')
: neu.docs.length ? h('div', { style: 'display:flex;flex-direction:column;gap:12px;' }, neu.docs.slice(0, 12).map(docBild))
: subdued('Noch keine Vorschau. „Aktualisieren" klicken (Klick aufs Bild = groß).'),
neu.mehrseitig ? h('div', { style: 'margin-top:14px;border-top:1px solid rgba(30,80,165,.15);padding-top:12px;' }, [
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:8px;' }, [
h('div', { style: `font-size:13px;font-weight:700;color:${ACCENT};` }, 'Seiten je Schriftstück'),
btn('Berechnen', seitenBerechnen),
]),
neu.seitenInfo
? h('pre', { style: 'margin:0;white-space:pre-wrap;font:inherit;font-size:13px;color:var(--foreground);' }, neu.seitenInfo)
: subdued('„Berechnen" klicken – zeigt, wie viele Seiten je Empfänger entstehen.'),
]) : null,
]);
return h('div', { style: 'display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap;' }, [links, rechts]);
}
// ── Unterschriftenservice ────────────────────────────────────────────────
async function sigLaden() {
sig.err = ''; sig.msg = ''; sig.pdf = ''; sig.order = null; sig.signaturen = []; sig.docs = []; sig.docId = null;
const nr = sig.orderNr.trim();
if (!nr) { sig.err = 'Bitte Auftragsnummer eingeben.'; return; }
sig.laden = true;
try {
const o = (await api.get('/items/orders', { params: {
filter: { order_number: { _eq: nr } }, limit: 1,
fields: 'id,order_number,source_file,source_files,customer.id',
} })).data?.data?.[0];
if (!o) { sig.err = `Auftrag ${nr} nicht gefunden.`; sig.laden = false; return; }
sig.order = o;
const docs = Array.isArray(o.source_files) && o.source_files.length
? o.source_files : (o.source_file ? [o.source_file] : []);
sig.docs = docs;
sig.docId = docs[0] || null;
if (o.customer?.id) {
const rows = (await api.get('/items/signatures', { params: {
filter: { customer: { _eq: o.customer.id } }, limit: -1,
fields: 'id,label,file,marker_key,offset_x_mm,offset_y_mm,width_mm',
} })).data?.data || [];
sig.signaturen = rows.filter((r) => r.file).map((r) => ({
file_id: r.file, label: r.label || 'Signatur', aktiv: true,
marker_key: r.marker_key || 'SIG1', position: 'genau',
width_mm: r.width_mm ?? 40, offset_x_mm: r.offset_x_mm ?? 0, offset_y_mm: r.offset_y_mm ?? 0,
}));
}
if (!sig.docs.length) sig.err = 'An diesem Auftrag ist kein Dokument hochgeladen.';
else if (!sig.signaturen.length) sig.err = 'Für diesen Kunden ist keine Unterschrift (mit Datei) hinterlegt.';
} catch (e) { sig.err = e?.response?.data?.errors?.[0]?.message || e?.message || 'Laden fehlgeschlagen.'; }
finally { sig.laden = false; }
}
const sigAktive = () => sig.signaturen.filter((s) => s.aktiv);
const sigPayload = () => ({ order: sig.order?.id || undefined, document_id: sig.docId || undefined, signatures: sigAktive() });
// PDF direkt hochladen (manueller Modus).
async function sigPdfUpload(file) {
if (!file) return;
sig.err = ''; sig.msg = 'Dokument wird hochgeladen …'; sig.pdf = '';
try { sig.docId = await sigUpload(file); sig.docName = file.name; sig.msg = ''; }
catch (e) { sig.err = 'Dokument-Upload fehlgeschlagen.'; sig.msg = ''; }
}
// Signatur-SVG hochladen und als Zeile hinzufügen (manuell oder ergänzend).
async function sigSvgUpload(file) {
if (!file) return;
sig.err = '';
try {
const id = await sigUpload(file);
sig.signaturen.push({ file_id: id, label: file.name, aktiv: true, marker_key: 'SIG' + (sig.signaturen.length + 1), position: 'genau', width_mm: 40, offset_x_mm: 0, offset_y_mm: 0 });
sig.pdf = '';
} catch (e) { sig.err = 'Signatur-Upload fehlgeschlagen.'; }
}
async function sigVorschau() {
if (!sig.docId) { sig.err = 'Bitte ein Dokument wählen/hochladen.'; return; }
if (!sigAktive().length) { sig.err = 'Mindestens eine Unterschrift aktivieren.'; return; }
sig.err = ''; sig.msg = 'Vorschau wird erzeugt …'; sig.pdf = '';
try {
const res = await api.post('/skrift-orders/signature-preview', sigPayload());
sig.pdf = 'data:application/pdf;base64,' + res.data.pdf_base64;
sig.msg = '';
} catch (e) { sig.err = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Vorschau fehlgeschlagen.'; sig.msg = ''; }
}
async function sigDownload() {
if (!sig.docId || !sigAktive().length) { sig.err = 'Dokument und Signatur wählen.'; return; }
sig.err = ''; sig.msg = 'A4-Signaturen werden erzeugt …';
try {
const res = await api.post('/skrift-orders/signature-render', sigPayload());
const bin = atob(res.data.zip_base64);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
const url = URL.createObjectURL(new Blob([arr], { type: 'application/zip' }));
const a = document.createElement('a');
a.href = url; a.download = `signaturen-${res.data.order_number || sig.order.order_number}.zip`;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 4000);
sig.msg = 'ZIP heruntergeladen – A4-Signaturen an den Plotter geben.';
} catch (e) { sig.err = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Erzeugung fehlgeschlagen.'; sig.msg = ''; }
}
function signaturPanel() {
const sigFeld = (s, k, lbl, step) => h('div', { style: 'display:flex;flex-direction:column;gap:3px;flex:1;min-width:90px;' }, [
h('label', { style: 'font-size:10px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);' }, lbl),
h('input', { value: s[k], type: k === 'marker_key' ? 'text' : 'number', step: step || 'any',
style: WFELD + 'width:100%;padding:7px 9px;font-size:13px;',
onInput: (e) => { s[k] = k === 'marker_key' ? e.target.value : (parseFloat(e.target.value) || 0); sig.pdf = ''; } }),
]);
const b1 = h('div', { style: BEREICH }, [
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;' }, [
bereichKopf('✍ Unterschriften'),
h('div', { style: 'width:280px;' }, [segToggle(sig.modus, [{ key: 'auftrag', label: 'Aus Auftrag' }, { key: 'manuell', label: 'Manuell' }], (k) => { sig.modus = k; sig.pdf = ''; })]),
]),
sig.modus === 'auftrag'
? h('div', {}, [
h('div', { style: 'display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;' }, [
h('div', { style: 'flex:1;min-width:200px;' }, [label('Auftragsnummer'), h('input', { value: sig.orderNr, placeholder: 'SK-2026-…', style: WFELD + 'width:100%;', onInput: (e) => { sig.orderNr = e.target.value; } })]),
btn(sig.laden ? 'Lädt …' : 'Auftrag laden', sigLaden, 'primary'),
]),
sig.docs.length > 1 ? h('div', { style: 'margin-top:12px;' }, [label('Dokument'), sel(sig.docId, sig.docs.map((d, i) => ({ v: d, t: `Dokument ${i + 1}` })), (e) => { sig.docId = e.target.value; sig.pdf = ''; })]) : null,
])
: h('div', {}, [
label('Dokument (PDF mit Marker §§SIG…§§)'),
h('input', { type: 'file', accept: '.pdf', onChange: (e) => sigPdfUpload(e.target.files && e.target.files[0]), style: 'font-size:13px;' }),
sig.docName ? subdued(`${sig.docName} hochgeladen.`, 'margin-top:6px;') : null,
]),
]);
const b2 = h('div', { style: BEREICH }, [
bereichKopf('Unterschriften & Position'),
subdued(sig.modus === 'auftrag'
? 'Aus dem Kundenkonto vorbelegt (Marker + Feinjustierung), hier überschreibbar. Weitere Unterschrift-SVGs sind ergänzbar.'
: 'Unterschrift(en) als SVG hochladen und Marker/Position setzen.', 'margin-bottom:10px;'),
h('div', { style: 'margin-bottom:10px;' }, [
h('label', { style: `display:inline-block;cursor:pointer;color:${ACCENT};font-size:13px;` }, [
'+ Unterschrift (SVG) hinzufügen',
h('input', { type: 'file', accept: '.svg', style: 'display:none;', onChange: (e) => sigSvgUpload(e.target.files && e.target.files[0]) }),
]),
]),
...sig.signaturen.map((s) => h('div', { style: 'background:#fff;border-radius:8px;padding:10px 12px;margin-bottom:8px;box-shadow:0 1px 2px rgba(0,0,0,.08);' }, [
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;font-weight:600;margin-bottom:8px;cursor:pointer;' }, [
h('input', { type: 'checkbox', checked: s.aktiv, onChange: (e) => { s.aktiv = e.target.checked; sig.pdf = ''; }, style: 'width:16px;height:16px;' }),
s.label,
]),
h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;' }, [
sigFeld(s, 'marker_key', 'Marker'),
sigFeld(s, 'width_mm', 'Breite mm'),
sigFeld(s, 'offset_x_mm', 'X mm'),
sigFeld(s, 'offset_y_mm', 'Y mm'),
]),
])),
h('div', { class: 'sk-btnrow', style: 'display:flex;gap:12px;flex-wrap:wrap;margin-top:8px;' }, [
btn(sig.msg ? '…' : '👁 Vorschau', sigVorschau, 'primary'),
btn('⤓ A4-Signaturen (ZIP)', sigDownload),
]),
]);
const meldung = h('div', {}, [
sig.err ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;margin-top:8px;' }, sig.err) : null,
sig.msg ? subdued(sig.msg, 'margin-top:8px;') : null,
]);
const links = h('div', { style: 'flex:1;min-width:0;display:flex;flex-direction:column;gap:16px;' }, [b1, b2, meldung]);
const rechts = h('div', { style: BEREICH + 'flex:0 0 420px;position:sticky;top:20px;align-self:flex-start;min-height:200px;' }, [
bereichKopf('👁 Vorschau'),
sig.pdf
? h('iframe', { src: sig.pdf, style: 'width:100%;height:70vh;border:none;border-radius:8px;background:#fff;' })
: subdued('Noch keine Vorschau. Auftrag laden, Signatur wählen, „Vorschau" klicken.'),
]);
return h('div', { style: 'display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap;' }, [links, rechts]);
}
// Linke Navigationsspalte: Werkzeuge dieses Moduls.
const navItem = (label, aktiv, onClick) => h('div', {
onClick,
style: 'display:flex;align-items:center;gap:8px;border-radius:6px;padding:10px 12px;margin-bottom:3px;font-size:14px;cursor:pointer;'
+ (aktiv ? `background:${ACCENT};color:#fff;font-weight:600;` : 'color:var(--foreground-normal);'),
}, label);
return () => h(resolveComponent('private-view'), { title: ansicht.value === 'signatur' ? 'Unterschriften' : 'Neuer Auftrag' }, {
navigation: () => h('div', { style: 'padding:12px 10px;' }, [
navItem('📝 Neuer Auftrag', ansicht.value === 'neu', () => { ansicht.value = 'neu'; }),
navItem('✍ Unterschriften', ansicht.value === 'signatur', () => { ansicht.value = 'signatur'; }),
]),
default: () => [
// Mobil: Button-Reihen bleiben in einer Zeile und füllen die Breite.
h('style', {}, '@media(max-width:640px){.sk-btnrow>button{flex:1 1 auto!important;}}'),
h('div', { style: 'padding:24px 28px;background:var(--background-subdued,#f1f4f9);min-height:100%;' },
ansicht.value === 'signatur' ? signaturPanel() : (!geladen.value ? subdued('Lädt …') : formular())),
neu.lb ? h(Teleport, { to: 'body' }, [
h('div', {
onClick: () => { neu.lb = ''; },
style: 'position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:1100;display:flex;align-items:center;justify-content:center;padding:4vh 4vw;cursor:zoom-out;',
}, [h('img', { src: neu.lb, style: 'max-width:100%;max-height:90vh;object-fit:contain;background:#fff;border-radius:8px;' })]),
]) : null,
],
});
},
});
export default {
id: 'skrift-neuauftrag',
name: 'Manueller Druck',
icon: 'print',
routes: [{ path: '', component: NeuAuftrag }],
};

View File

@@ -0,0 +1,13 @@
{
"name": "directus-extension-skrift-neuauftrag",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Modul: Neuer Auftrag – manuelle Auftragsanlage (Format/Kuvert/Schrift, Struktur- oder 5-Zeilen-Adressen, Vorschau) über die zentralen Backend-Endpunkte.",
"directus:extension": {
"type": "module",
"path": "dist/index.js",
"source": "dist/index.js",
"host": "^11.0.0"
}
}

View File

@@ -0,0 +1,149 @@
/**
* Skrift – Interface „Empfänger (durchsuchbar)"
* ---------------------------------------------------------------------------
* Zeigt im Auftrag alle Empfängerzeilen als kompakte, durchsuchbare Tabelle –
* die Produktionsansicht aus dem alten Job-Manager: pro Zeile die Briefnummer
* und die VOLLE Anschrift bzw. der Freitext (nicht nur der Name). Das Suchfeld
* filtert über die ganze Zeile (Name, Straße, Ort, Land, Platzhalter …).
*
* Rein lesend/suchend – bearbeitet wird eine Zeile weiterhin über das darunter
* liegende o2m-Feld „Empfänger (bearbeiten)".
*
* Bewusst von Hand als ESM geschrieben (kein Build-Schritt): Vue und der
* Extensions-SDK werden von Directus zur Laufzeit bereitgestellt.
*/
import { defineComponent, ref, computed, inject, watch, onMounted, h } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const INLAND = /^(de|deutschland|germany)$/i;
/** Baut die volle Anzeigezeile einer Empfängerzeile. */
function volleZeile(e) {
if (e.free_text && String(e.free_text).trim()) {
return String(e.free_text).trim().split(/\r?\n/).map((s) => s.trim()).filter(Boolean).join(' · ');
}
const name = [e.salutation, e.first_name, e.last_name].filter(Boolean).join(' ');
const strasse = [e.street, e.house_no].filter(Boolean).join(' ');
const ort = [e.zip, e.city].filter(Boolean).join(' ');
const land = e.country && !INLAND.test(String(e.country).trim()) ? e.country : null;
return [name, strasse, ort, land].filter(Boolean).join(' · ');
}
/** Zusätzliche freie Platzhalter als „Key: Wert"-Liste. */
function extras(e) {
if (!e.placeholders || typeof e.placeholders !== 'object') return '';
return Object.entries(e.placeholders)
.filter(([, v]) => v !== null && v !== undefined && String(v).trim() !== '')
.map(([k, v]) => `${k}: ${v}`).join(' · ');
}
const EmpfaengerSuche = defineComponent({
setup() {
const api = useApi();
const values = inject('values', ref({}));
const rows = ref([]);
const loading = ref(false);
const error = ref('');
const geladen = ref(false);
const suche = ref('');
const orderId = () => (values && values.value ? values.value.id : null);
async function laden() {
const id = orderId();
if (!id) { rows.value = []; geladen.value = true; return; }
loading.value = true; error.value = '';
try {
const res = await api.get('/items/order_entries', {
params: {
'filter[order][_eq]': id,
sort: 'letter_number',
limit: -1,
fields: '*',
},
});
const data = (res.data && res.data.data) || [];
rows.value = data.map((e) => ({
nr: e.letter_number,
zeile: volleZeile(e),
extra: extras(e),
}));
} catch (e) {
error.value = (e && e.response && e.response.data && e.response.data.errors &&
e.response.data.errors[0] && e.response.data.errors[0].message) ||
(e && e.message) || 'Empfänger konnten nicht geladen werden.';
} finally {
loading.value = false; geladen.value = true;
}
}
onMounted(laden);
watch(orderId, laden);
const treffer = computed(() => {
const q = suche.value.trim().toLowerCase();
if (!q) return rows.value;
return rows.value.filter((r) =>
(`#${r.nr} ${r.zeile} ${r.extra}`).toLowerCase().includes(q));
});
const hinweis = (txt) => h('div', { style: 'color:var(--foreground-subdued);font-size:14px;padding:8px 0;' }, txt);
return () => {
if (loading.value) return hinweis('Empfänger werden geladen …');
if (error.value) return hinweis(error.value);
if (!geladen.value) return hinweis('');
if (!rows.value.length) return hinweis('Noch keine Empfängerzeilen.');
const suchfeld = h('div', { style: 'display:flex;align-items:center;gap:10px;margin-bottom:10px;' }, [
h('input', {
type: 'search',
value: suche.value,
placeholder: 'Empfänger durchsuchen (Name, Straße, Ort, Platzhalter …)',
onInput: (ev) => { suche.value = ev.target.value; },
style: 'flex:1;padding:8px 12px;border:1px solid var(--border-normal);border-radius:6px;' +
'background:var(--background-page);color:var(--foreground-normal);font:inherit;font-size:14px;',
}),
h('span', { style: 'font-size:12px;color:var(--foreground-subdued);white-space:nowrap;' },
`${treffer.value.length} von ${rows.value.length}`),
]);
const zeilen = treffer.value.map((r) =>
h('div', {
key: r.nr,
style: 'display:flex;align-items:baseline;gap:12px;padding:7px 12px;' +
'border:1px solid var(--border-subdued);border-radius:6px;',
}, [
h('span', {
style: 'min-width:44px;font-family:var(--family-monospace, monospace);font-size:13px;' +
'font-weight:600;color:var(--primary);',
}, `#${r.nr}`),
h('div', { style: 'flex:1;font-size:14px;line-height:1.4;word-break:break-word;' }, [
h('span', {}, r.zeile || '—'),
r.extra
? h('span', { style: 'display:block;font-size:12px;color:var(--foreground-subdued);margin-top:2px;' }, r.extra)
: null,
]),
]),
);
const body = treffer.value.length
? h('div', { style: 'display:flex;flex-direction:column;gap:5px;' }, zeilen)
: hinweis('Keine Treffer.');
return h('div', {}, [suchfeld, body]);
};
},
});
export default {
id: 'skrift-order-entries',
name: 'Empfänger (durchsuchbar)',
icon: 'search',
description: 'Durchsuchbare Empfängertabelle: Briefnummer + volle Anschrift (Produktionsansicht).',
component: EmpfaengerSuche,
types: ['alias'],
localTypes: ['presentation'],
group: 'presentation',
options: null,
};

View File

@@ -0,0 +1,13 @@
{
"name": "directus-extension-skrift-orderentries",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Interface: durchsuchbare Empfängertabelle eines Auftrags (Briefnummer + volle Anschrift) – Produktionsansicht wie im alten Job-Manager.",
"directus:extension": {
"type": "interface",
"path": "dist/index.js",
"source": "dist/index.js",
"host": "^11.0.0"
}
}

View File

@@ -0,0 +1,105 @@
/**
* Skrift – Interface „Auftragsdateien"
* ---------------------------------------------------------------------------
* Zeigt im Auftrag die generierten Dateien (Schriftstücke, Umschläge,
* Platzhalter-CSV) direkt aus dem gemounteten Ausgabe-Ordner an – ohne Kopie
* in den Directus-Speicher. Die Liste und der Download laufen über den
* Endpoint /skrift-orders/files/* und die angemeldete Directus-Session
* (kein Token in der URL nötig).
*
* Bewusst von Hand als ESM geschrieben (kein Build-Schritt): Vue und der
* Extensions-SDK werden von Directus zur Laufzeit bereitgestellt.
*/
import { defineComponent, ref, inject, watch, onMounted, h } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const KIND_LABEL = {
schriftstueck: 'Schriftstück',
umschlag: 'Umschlag',
platzhalter: 'Platzhalter-CSV',
sonstiges: 'Datei',
};
const AuftragsDateien = defineComponent({
setup() {
const api = useApi();
const values = inject('values', ref({}));
const files = ref([]);
const loading = ref(false);
const error = ref('');
const geladen = ref(false);
function nummer() {
return values && values.value ? values.value.order_number : null;
}
async function laden() {
const nr = nummer();
if (!nr) { files.value = []; geladen.value = true; return; }
loading.value = true; error.value = '';
try {
const res = await api.get('/skrift-orders/files/' + encodeURIComponent(nr));
files.value = (res.data && res.data.files) || [];
} catch (e) {
error.value = (e && e.response && e.response.data && e.response.data.error) || (e && e.message) || 'Fehler beim Laden.';
} finally {
loading.value = false; geladen.value = true;
}
}
async function download(f) {
const nr = nummer();
if (!nr) return;
try {
const res = await api.get('/skrift-orders/files/' + encodeURIComponent(nr) + '/download', {
params: { rel: f.rel }, responseType: 'blob',
});
const url = URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = url; a.download = f.name;
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
} catch {
error.value = 'Download fehlgeschlagen.';
}
}
onMounted(laden);
watch(nummer, laden);
const hinweis = (txt) => h('div', { style: 'color:var(--foreground-subdued);font-size:14px;padding:8px 0;' }, txt);
return () => {
if (loading.value) return hinweis('Dateien werden geladen …');
if (error.value) return hinweis(error.value);
if (!files.value.length) return hinweis(geladen.value ? 'Noch keine generierten Dateien.' : '');
return h('div', { style: 'display:flex;flex-direction:column;gap:6px;' }, files.value.map((f) =>
h('div', {
key: f.rel,
style: 'display:flex;align-items:center;gap:12px;padding:8px 12px;border:1px solid var(--border-normal);border-radius:6px;',
}, [
h('span', { style: 'font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);min-width:110px;' },
KIND_LABEL[f.kind] || 'Datei'),
h('span', { style: 'flex:1;font-family:var(--family-monospace, monospace);font-size:13px;word-break:break-all;' }, f.name),
h('button', {
type: 'button',
onClick: () => download(f),
style: 'cursor:pointer;border:1px solid var(--border-normal);background:var(--background-subdued);border-radius:6px;padding:4px 12px;font:inherit;font-size:13px;',
}, 'Herunterladen'),
]),
));
};
},
});
export default {
id: 'skrift-order-files',
name: 'Auftragsdateien',
icon: 'folder_open',
description: 'Zeigt die generierten Dateien des Auftrags (Direktzugriff auf den Server-Ordner).',
component: AuftragsDateien,
types: ['alias'],
localTypes: ['presentation'],
group: 'presentation',
options: null,
};

View File

@@ -0,0 +1,13 @@
{
"name": "directus-extension-skrift-orderfiles",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Interface: zeigt die generierten Auftragsdateien per Direktzugriff auf den Ausgabe-Ordner (kein Download in Directus, keine Kopie).",
"directus:extension": {
"type": "interface",
"path": "dist/index.js",
"source": "dist/index.js",
"host": "^11.0.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{
"name": "directus-extension-skrift-orders",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Kontrollierter Bestell-Endpunkt (Gast + eingeloggt) mit serverseitiger Preisberechnung.",
"directus:extension": {
"type": "endpoint",
"path": "dist/index.js",
"source": "dist/index.js",
"host": "^11.0.0"
}
}

View File

@@ -0,0 +1,83 @@
/**
* Skrift – Interface „Drucken"
* ---------------------------------------------------------------------------
* Zeigt im Auftrag eine Maschinen-Auswahl + „Drucken"-Button. Der Button reiht
* den Auftrag über POST /skrift-orders/enqueue als Druck-Job(s) ein (Brief +
* ggf. Umschlag, mit aufgelöstem Template). Läuft über die angemeldete Session.
*
* Bewusst von Hand als ESM (kein Build-Schritt) – Vue + SDK stellt Directus bereit.
*/
import { defineComponent, ref, inject, onMounted, h } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const PrintButton = defineComponent({
setup() {
const api = useApi();
const values = inject('values', ref({}));
const machines = ref([]);
const selected = ref(null);
const busy = ref(false);
const msg = ref('');
const err = ref('');
async function ladeMaschinen() {
try {
const res = await api.get('/items/machines', { params: { fields: 'id,name,active', limit: -1 } });
machines.value = (res.data?.data || []).filter((m) => m.active !== false);
if (machines.value.length && selected.value == null) selected.value = machines.value[0].id;
} catch {
err.value = 'Maschinen konnten nicht geladen werden.';
}
}
onMounted(ladeMaschinen);
async function drucken() {
const orderId = values && values.value ? values.value.id : null;
if (!orderId) { err.value = 'Auftrag zuerst speichern.'; return; }
if (selected.value == null) { err.value = 'Bitte eine Maschine wählen.'; return; }
busy.value = true; err.value = ''; msg.value = '';
try {
const res = await api.post('/skrift-orders/enqueue', { order: orderId, machine: selected.value });
const n = (res.data?.jobs || []).length;
msg.value = `${n} Druck-Job(s) eingereiht.`;
} catch (e) {
err.value = (e && e.response && e.response.data && e.response.data.error) || (e && e.message) || 'Fehler beim Einreihen.';
} finally {
busy.value = false;
}
}
return () => {
if (!machines.value.length) {
return h('div', { style: 'color:var(--foreground-subdued);font-size:13px;' },
err.value || 'Keine aktive Maschine – bitte unter Einstellungen → Maschinen anlegen.');
}
return h('div', { style: 'display:flex;flex-direction:column;gap:8px;' }, [
h('div', { style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;' }, [
h('select', {
style: 'padding:6px 10px;border:1px solid var(--border-normal);border-radius:6px;background:var(--background-page);font:inherit;',
onChange: (e) => { selected.value = e.target.value; },
}, machines.value.map((m) => h('option', { value: m.id, selected: String(m.id) === String(selected.value) }, m.name))),
h('button', {
type: 'button', disabled: busy.value, onClick: drucken,
style: 'cursor:pointer;border:1px solid var(--primary);background:var(--primary);color:#fff;border-radius:6px;padding:6px 16px;font:inherit;',
}, busy.value ? 'Wird eingereiht …' : 'Drucken'),
]),
msg.value ? h('div', { style: 'color:var(--success, #2e7d32);font-size:13px;' }, msg.value) : null,
err.value ? h('div', { style: 'color:var(--danger, #c62828);font-size:13px;' }, err.value) : null,
]);
};
},
});
export default {
id: 'skrift-print-button',
name: 'Drucken',
icon: 'print',
description: 'Reiht den Auftrag als Druck-Job für eine Maschine ein.',
component: PrintButton,
types: ['alias'],
localTypes: ['presentation'],
group: 'presentation',
options: null,
};

View File

@@ -0,0 +1,13 @@
{
"name": "directus-extension-skrift-printbutton",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Interface: reiht einen Auftrag als Druck-Job für eine Maschine ein (POST /skrift-orders/enqueue).",
"directus:extension": {
"type": "interface",
"path": "dist/index.js",
"source": "dist/index.js",
"host": "^11.0.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{
"name": "directus-extension-skrift-produktion",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Modul: Produktions-Kommandozentrale (Status-Board, Druck-Popup, Empfänger/Dateien, Muster-Druck).",
"directus:extension": {
"type": "module",
"path": "dist/index.js",
"source": "dist/index.js",
"host": "^11.0.0"
}
}

View File

@@ -0,0 +1,59 @@
/**
* Skrift – Interface „Testdruck generieren & drucken"
* ---------------------------------------------------------------------------
* Button im test_prints-Datensatz: erzeugt aus Template + Text ein SVG und
* reiht es als Druck-Job für die gewählte Maschine ein
* (POST /skrift-orders/test-print). Datensatz vorher speichern.
*
* Von Hand als ESM (kein Build) – Vue + SDK stellt Directus zur Laufzeit bereit.
*/
import { defineComponent, ref, inject, h } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const TestPrintButton = defineComponent({
setup() {
const api = useApi();
const values = inject('values', ref({}));
const busy = ref(false);
const msg = ref('');
const err = ref('');
async function ausloesen() {
const id = values && values.value ? values.value.id : null;
if (!id) { err.value = 'Bitte den Datensatz zuerst speichern.'; return; }
busy.value = true; err.value = ''; msg.value = '';
try {
const res = await api.post('/skrift-orders/test-print', { id });
msg.value = `Generiert & eingereiht (Job ${res.data?.job}, Ordner ${res.data?.ref}).`;
} catch (e) {
const d = e && e.response && e.response.data;
err.value = (d && (d.error + (d.detail ? ' – ' + d.detail : ''))) || (e && e.message) || 'Fehler.';
} finally {
busy.value = false;
}
}
return () => h('div', { style: 'display:flex;flex-direction:column;gap:8px;' }, [
h('button', {
type: 'button', disabled: busy.value, onClick: ausloesen,
style: 'align-self:flex-start;cursor:pointer;border:1px solid var(--primary);background:var(--primary);color:#fff;border-radius:6px;padding:8px 18px;font:inherit;',
}, busy.value ? 'Wird generiert …' : 'Generieren & drucken'),
msg.value ? h('div', { style: 'color:var(--success, #2e7d32);font-size:13px;' }, msg.value) : null,
err.value ? h('div', { style: 'color:var(--danger, #c62828);font-size:13px;' }, err.value) : null,
h('div', { style: 'color:var(--foreground-subdued);font-size:12px;' },
'Hinweis: Datensatz mit Template, Format, Maschine und Text speichern, dann klicken.'),
]);
},
});
export default {
id: 'skrift-testprint-button',
name: 'Testdruck',
icon: 'science',
description: 'Generiert einen Testdruck und reiht ihn zum Drucken ein.',
component: TestPrintButton,
types: ['alias'],
localTypes: ['presentation'],
group: 'presentation',
options: null,
};

View File

@@ -0,0 +1,13 @@
{
"name": "directus-extension-skrift-testprint",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Interface: generiert einen Testdruck aus einem Template + Text und reiht ihn zum Drucken ein (POST /skrift-orders/test-print).",
"directus:extension": {
"type": "interface",
"path": "dist/index.js",
"source": "dist/index.js",
"host": "^11.0.0"
}
}