diff --git a/bootstrap/bootstrap.mjs b/bootstrap/bootstrap.mjs index c5a907a..6ce35d9 100644 --- a/bootstrap/bootstrap.mjs +++ b/bootstrap/bootstrap.mjs @@ -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: `

${G}

vielen Dank – Ihr Auftrag {{order_number}} für den Unterschriftenservice ist bei uns eingegangen. Wir prüfen Ihr Dokument und melden uns mit den nächsten Schritten.

` }, { key: 'extern_eingang', label: 'Externe Bestellung: Eingang', subject: 'Ihre Bestellung {{order_number}} ist eingegangen', append_signature: true, attach_agb: false, body: `

${G}

vielen Dank – Ihre Bestellung {{order_number}} ist bei uns eingegangen und geht direkt in die Produktion.

` }, + // 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: `

${G}

vielen Dank für Ihre Bestellung {{order_number}}! Wir haben aus Ihrem Motiv einen Druck-Entwurf (A6) erstellt.

` + + `

Bitte prüfen und freigeben Sie den Entwurf – oder hinterlegen Sie einen Änderungswunsch:

` + + `

Entwurf ansehen & freigeben

` + + `

Erst nach Ihrer Freigabe geht die Karte in den Druck.

` }, ]; for (const t of templates) await seedItem('email_templates', 'key', { enabled: true, ...t }); diff --git a/extensions/directus-extension-skrift-orders/dist/index.js b/extensions/directus-extension-skrift-orders/dist/index.js index 7f119c0..3ca6d56 100644 --- a/extensions/directus-extension-skrift-orders/dist/index.js +++ b/extensions/directus-extension-skrift-orders/dist/index.js @@ -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 = `` + + `` + + ``; + 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) => ({ '<': '<', '>': '>', '&': '&' }[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' + ? `

Der Kunde hat den Motiv-Entwurf zu ${escHtml(o.order_number)} freigegeben – kann in den Druck.

` + : `

Änderungswunsch zum Motiv-Entwurf ${escHtml(o.order_number)}:

${escHtml(note)}

`); + 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); } diff --git a/extensions/directus-extension-skrift-produktion/dist/index.js b/extensions/directus-extension-skrift-produktion/dist/index.js index 61218b2..55ba6b9 100644 --- a/extensions/directus-extension-skrift-produktion/dist/index.js +++ b/extensions/directus-extension-skrift-produktion/dist/index.js @@ -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.