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

@@ -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);
}