Motiv-Freigabe: A6-Entwurf erzeugen + Kundenfreigabe (Backend/Board)

- skrift-orders: bei Konfigurator-Bestellung mit Motiv-Upload wird ein A6-
  Drucklayout (152×109 mm, 2 mm Beschnitt, Motiv mittig/randabfallend) erzeugt
  und als orders.motif_layout abgelegt; approval_status='pending' + Token gesetzt;
  Freigabe-Mail (motiv_freigabe) mit Einmal-Link verschickt. Öffentliche
  Endpunkte /freigabe/:token (Info), /:token/layout (SVG), POST (freigeben/ändern).
- bootstrap: orders.approval_status/approval_token/approval_note/motif_layout,
  users.sevdesk_contact_id (bereits), Mailvorlage motiv_freigabe.
- Produktion: Chip „wartet auf Freigabe / freigegeben / Änderungswunsch".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
s4luorth
2026-09-18 08:05:39 +02:00
parent 0f73a653b3
commit 881ff8a27b
3 changed files with 133 additions and 3 deletions

View File

@@ -272,6 +272,12 @@ const collections = [
{ default: 'eingegangen', note: 'Produktions-/Kanban-Status.' }),
f('brief_gedruckt', 'boolean', { interface: 'boolean', default: false, note: 'Schriftstücke gedruckt (vom Agent gesetzt).' }),
f('kuvert_gedruckt', 'boolean', { interface: 'boolean', default: false, note: 'Umschläge gedruckt (vom Agent gesetzt).' }),
// Motiv-Freigabe (Postkarten mit Motiv-Upload): Kunde genehmigt den A6-Entwurf.
dropdown('approval_status', ['none', 'pending', 'approved', 'changes'],
{ default: 'none', note: 'Motiv-Freigabe: none=keine, pending=wartet auf Freigabe, approved=freigegeben, changes=Änderungswunsch.' }),
f('approval_token', 'string', { interface: 'input', hidden: true, note: 'Token für den Freigabe-Link (Einmal-Zugang zum Entwurf).' }),
f('approval_note', 'text', { interface: 'input-multiline', note: 'Änderungswunsch des Kunden zum Motiv-Entwurf.' }),
m2o('motif_layout', 'directus_files', { note: 'Generiertes A6-Drucklayout (Motiv mittig, randabfallend) zur Freigabe.' }),
// Zeitpunkt des Versands – Basis fürs Auto-Archiv (nach 14 Tagen).
{ field: 'versandt_am', type: 'timestamp', meta: { interface: 'datetime', note: 'Wird beim Wechsel auf „versendet" gesetzt.' }, schema: { is_nullable: true } },
{ field: 'production_status_changed_at', type: 'timestamp', meta: { interface: 'datetime', note: 'Zeitpunkt der letzten Produktionsstatus-Änderung (für „status_since" der Public-API).' }, schema: { is_nullable: true } },
@@ -925,6 +931,12 @@ async function applySeed() {
body: `<p>${G}</p><p>vielen Dank – Ihr Auftrag <strong>{{order_number}}</strong> für den Unterschriftenservice ist bei uns eingegangen. Wir prüfen Ihr Dokument und melden uns mit den nächsten Schritten.</p>` },
{ key: 'extern_eingang', label: 'Externe Bestellung: Eingang', subject: 'Ihre Bestellung {{order_number}} ist eingegangen', append_signature: true, attach_agb: false,
body: `<p>${G}</p><p>vielen Dank – Ihre Bestellung <strong>{{order_number}}</strong> ist bei uns eingegangen und geht direkt in die Produktion.</p>` },
// Motiv-Freigabe: Kunde genehmigt den A6-Entwurf über den Link (Einmal-Zugang).
{ key: 'motiv_freigabe', label: 'Motiv: Freigabe anfordern', subject: 'Bitte Ihren Postkarten-Entwurf freigeben – {{order_number}}', append_signature: true, attach_agb: false,
body: `<p>${G}</p><p>vielen Dank für Ihre Bestellung <strong>{{order_number}}</strong>! Wir haben aus Ihrem Motiv einen Druck-Entwurf (A6) erstellt.</p>`
+ `<p>Bitte prüfen und <strong>freigeben</strong> Sie den Entwurf – oder hinterlegen Sie einen Änderungswunsch:</p>`
+ `<p><a href="{{freigabe_link}}" style="display:inline-block;background:#1E50A5;color:#fff;text-decoration:none;padding:12px 22px;border-radius:8px;font-weight:600;">Entwurf ansehen &amp; freigeben</a></p>`
+ `<p style="font-size:13px;color:#666;">Erst nach Ihrer Freigabe geht die Karte in den Druck.</p>` },
];
for (const t of templates) await seedItem('email_templates', 'key', { enabled: true, ...t });

View File

@@ -202,6 +202,30 @@ const handler = (router, { services, getSchema, logger }) => {
return fs.readFileSync(path.join(UPLOAD_ROOT, f.filename_disk));
}
/** Erzeugt aus einem hochgeladenen Motiv das A6-Drucklayout (152×109 mm, 2 mm
* Beschnitt, Endformat 148×105) – Motiv mittig, randabfallend – und legt es als
* Datei ab (orders.motif_layout). Rein serverseitig, für die Kundenfreigabe. */
async function erzeugeMotivEntwurf(schema, orderId, fileId) {
const meta = await new ItemsService('directus_files', { schema, accountability: null })
.readOne(fileId, { fields: ['type', 'filename_disk'] });
if (!meta?.filename_disk) throw new Error(`Motiv-Datei ${fileId} nicht gefunden.`);
const base64 = fs.readFileSync(path.join(UPLOAD_ROOT, meta.filename_disk)).toString('base64');
const mime = meta.type || 'image/png';
// A6 quer randabfallend: Motiv füllt die volle Beschnittfläche (cover), mittig.
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="152mm" height="109mm" viewBox="0 0 152 109">`
+ `<image href="data:${mime};base64,${base64}" x="0" y="0" width="152" height="109" preserveAspectRatio="xMidYMid slice"/>`
+ `</svg>`;
const files = new FilesService({ schema, accountability: null });
const storage = (process.env.STORAGE_LOCATIONS || 'local').split(',')[0].trim() || 'local';
const layoutId = await files.uploadOne(
Readable.from(Buffer.from(svg, 'utf8')),
{ storage, filename_download: `a6-entwurf-${orderId}.svg`, title: `A6-Entwurf ${orderId}`, type: 'image/svg+xml' },
undefined, { emitEvents: false },
);
await new ItemsService('orders', { schema, accountability: null }).updateOne(orderId, { motif_layout: layoutId });
return layoutId;
}
/** Baut die Multipart-Anfrage an den Signatur-Service (Vorschau/Render). */
async function signatureForm(schema, pdfId, sigList) {
const pdfBytes = await dateiBytes(schema, pdfId);
@@ -914,6 +938,79 @@ const handler = (router, { services, getSchema, logger }) => {
}
});
// ── Motiv-Freigabe (öffentlich, per Einmal-Token) ──────────────────────────
const escHtml = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
const freigabeAuftrag = async (schema, token, fields) => {
const t = String(token || '').trim();
if (!t) return null;
return (await new ItemsService('orders', { schema, accountability: null }).readByQuery({
filter: { approval_token: { _eq: t } }, limit: 1, fields,
}))?.[0] || null;
};
// Auftrags-/Statusinfo für die Freigabe-Seite (kein Login nötig – Token genügt).
router.get('/freigabe/:token', async (req, res) => {
try {
const schema = await getSchema();
const o = await freigabeAuftrag(schema, req.params.token,
['id', 'order_number', 'approval_status', 'approval_note', 'motif_layout', 'product.name']);
if (!o) return res.status(404).json({ error: 'Freigabe-Link ungültig oder abgelaufen.' });
return res.json({
order_number: o.order_number,
product: o.product?.name || 'Postkarte',
status: o.approval_status || 'pending',
note: o.approval_note || '',
has_layout: !!o.motif_layout,
});
} catch (e) {
logger.error(`[skrift-orders] freigabe GET: ${e.message}`);
return res.status(500).json({ error: 'Fehler beim Laden.' });
}
});
// Das A6-Entwurfs-SVG direkt ausliefern (Token-gated, kein Asset-Login nötig).
router.get('/freigabe/:token/layout', async (req, res) => {
try {
const schema = await getSchema();
const o = await freigabeAuftrag(schema, req.params.token, ['motif_layout']);
if (!o?.motif_layout) return res.status(404).send('Kein Entwurf.');
const bytes = await dateiBytes(schema, o.motif_layout);
res.setHeader('Content-Type', 'image/svg+xml');
res.setHeader('Cache-Control', 'no-store');
return res.send(bytes);
} catch (e) {
logger.error(`[skrift-orders] freigabe layout: ${e.message}`);
return res.status(500).send('Fehler.');
}
});
// Freigabe oder Änderungswunsch speichern + Team benachrichtigen.
router.post('/freigabe/:token', async (req, res) => {
const action = String(req.body?.action || '');
const note = String(req.body?.note || '').slice(0, 2000).trim();
if (!['approve', 'changes'].includes(action)) return res.status(400).json({ error: 'Ungültige Aktion.' });
if (action === 'changes' && !note) return res.status(400).json({ error: 'Bitte den Änderungswunsch beschreiben.' });
try {
const schema = await getSchema();
const svc = (c) => new ItemsService(c, { schema, accountability: null });
const o = await freigabeAuftrag(schema, req.params.token, ['id', 'order_number', 'approval_status']);
if (!o) return res.status(404).json({ error: 'Freigabe-Link ungültig oder abgelaufen.' });
const neu = action === 'approve' ? 'approved' : 'changes';
await svc('orders').updateOne(o.id, { approval_status: neu, approval_note: action === 'changes' ? note : null });
await svc('status_history').createOne({ order: o.id, status: 'eingegangen',
note: action === 'approve' ? 'Motiv-Entwurf vom Kunden freigegeben.' : `Motiv-Änderungswunsch: ${note}` }).catch(() => {});
await teamMail(schema,
action === 'approve' ? `✅ Motiv freigegeben: ${o.order_number}` : `✏️ Motiv-Änderungswunsch: ${o.order_number}`,
action === 'approve'
? `<p>Der Kunde hat den Motiv-Entwurf zu <strong>${escHtml(o.order_number)}</strong> freigegeben – kann in den Druck.</p>`
: `<p>Änderungswunsch zum Motiv-Entwurf <strong>${escHtml(o.order_number)}</strong>:</p><p style="white-space:pre-wrap">${escHtml(note)}</p>`);
return res.json({ ok: true, status: neu });
} catch (e) {
logger.error(`[skrift-orders] freigabe POST: ${e.message}`);
return res.status(500).json({ error: 'Konnte nicht gespeichert werden.' });
}
});
/** Aktuelles Kontingent abfragen, ohne etwas zu verbrauchen. */
router.get('/preview/quota', async (req, res) => {
try {
@@ -2053,11 +2150,29 @@ const handler = (router, { services, getSchema, logger }) => {
order: orderId, status: 'wartet_auf_zahlung', note: 'Auftrag über den Konfigurator angelegt.',
});
// Motiv-Upload (Postkarte) → A6-Entwurf erzeugen + Kundenfreigabe anfordern.
let freigabeToken = null;
const istMotivUpload = body.motif_mode === 'upload' && (body.motif_upload || body.source_file);
if (istMotivUpload) {
freigabeToken = crypto.randomBytes(24).toString('base64url');
try { await svc('orders').updateOne(orderId, { approval_status: 'pending', approval_token: freigabeToken }); }
catch (e) { logger.warn(`[skrift-orders] Freigabe-Setup: ${e.message}`); }
// A6-Layout asynchron erzeugen – blockiert die Antwort an den Kunden nicht.
erzeugeMotivEntwurf(schema, orderId, body.motif_upload || body.source_file)
.catch((e) => logger.warn(`[skrift-orders] Motiv-Entwurf ${orderNumber}: ${e.message}`));
}
// Separate, im Admin editierbare Kunden-Mail für Sonderfälle.
const kundeEmail = norm(body.email);
if (isEmail(kundeEmail)) {
const vars = { order_number: orderNumber, name: [body.first_name, body.last_name].filter(Boolean).join(' ') };
if (quote.product?.input_mode === 'datei') sendeVorlage(schema, 'unterschriften_eingang', kundeEmail, vars);
let portal = (process.env.CORS_ORIGIN || 'https://app.skrift.de').split(',')[0].trim();
try { const es = await svc('email_settings').readSingleton({ fields: ['portal_url'] }); if (es?.portal_url) portal = String(es.portal_url).trim(); } catch { /* Default */ }
const vars = {
order_number: orderNumber, name: [body.first_name, body.last_name].filter(Boolean).join(' '),
freigabe_link: freigabeToken ? `${portal.replace(/\/$/, '')}/freigabe/${freigabeToken}` : '',
};
if (istMotivUpload) sendeVorlage(schema, 'motiv_freigabe', kundeEmail, vars);
else if (quote.product?.input_mode === 'datei') sendeVorlage(schema, 'unterschriften_eingang', kundeEmail, vars);
else if (body.source === 'extern') sendeVorlage(schema, 'extern_eingang', kundeEmail, vars);
}

View File

@@ -206,7 +206,7 @@ const Board = defineComponent({
+ 'envelope_labeling,shipping_type,shipping_day,keep_contacts,entries_count,count_inland,count_ausland,brief_gedruckt,kuvert_gedruckt,'
+ 'date_updated,date_created,format.key,format.name,product.name,product.input_mode,'
+ 'source,api_client.id,api_client.name,'
+ 'source_file,internal_note,linked_order.id,linked_order.order_number,'
+ 'source_file,internal_note,approval_status,approval_note,linked_order.id,linked_order.order_number,'
+ 'customer.first_name,customer.last_name,customer.email,customer.phone';
async function ladeOrders() {
try {
@@ -1078,6 +1078,9 @@ const Board = defineComponent({
chip(o.payment_status === 'bezahlt' ? '✓ bezahlt' : (o.payment_status === 'storniert' ? 'storniert' : 'offen'),
o.payment_status === 'bezahlt' ? 'var(--success-25,rgba(46,125,50,.15))' : 'var(--warning-25,rgba(230,160,0,.15))'),
o.linked_order?.order_number ? chip('🔗 Kuverts: #' + o.linked_order.order_number, 'var(--primary-25,rgba(30,80,165,.14))') : null,
o.approval_status === 'pending' ? chip('⏳ wartet auf Freigabe', 'var(--warning-25,rgba(230,160,0,.2))') : null,
o.approval_status === 'approved' ? chip('✓ Motiv freigegeben', 'var(--success-25,rgba(46,125,50,.15))') : null,
o.approval_status === 'changes' ? chip('✏ Änderungswunsch', 'var(--danger-25,rgba(198,40,40,.15))') : null,
]);
// „Auf einen Blick": welche Nummern wurden schon in den Druck gesendet
// (aus den Jobs, inkl. bereits gedruckter). Kein Status, nur die Eingabe.