From 3a902e71386185b831ed6d7a3b2f09eaaa84b7cd Mon Sep 17 00:00:00 2001 From: LagoESP Date: Wed, 29 Apr 2026 21:42:17 +0200 Subject: [PATCH] feat: add Mietvertrag workflow and template management in n8n, including email notifications and document handling Co-authored-by: Copilot --- data/n8n/.n8n/n8nEventLog.log | 4 + docker-compose.local.yml | 1 + docker-compose.yml | 18 ++ frontend/admin.html | 10 + frontend/admin.js | 42 +++ frontend/i18n.js | 6 + .../01-qualification-payment-email.json | 96 ++++++ n8n/workflows/02-mietvertrag-pdf-email.json | 291 ++++++++++++++++++ n8n/workflows/README.md | 88 ++++++ .../migrations/10-mietvertrag-workflow.sql | 66 ++++ 10 files changed, 622 insertions(+) create mode 100644 n8n/workflows/01-qualification-payment-email.json create mode 100644 n8n/workflows/02-mietvertrag-pdf-email.json create mode 100644 n8n/workflows/README.md create mode 100644 supabase/migrations/10-mietvertrag-workflow.sql diff --git a/data/n8n/.n8n/n8nEventLog.log b/data/n8n/.n8n/n8nEventLog.log index 1763a5a..5b6de1d 100644 --- a/data/n8n/.n8n/n8nEventLog.log +++ b/data/n8n/.n8n/n8nEventLog.log @@ -34,3 +34,7 @@ {"__type":"$$EventMessageConfirm","confirm":"eb33d71c-ab1e-45e7-b8a1-c2f4233db4cc","ts":"2026-04-29T20:02:02.388+02:00","source":{"id":"0","name":"eventBus"}} {"__type":"$$EventMessageWorkflow","id":"0da331f2-66a0-45fd-b691-d4c28a01598c","ts":"2026-04-29T20:02:02.404+02:00","eventName":"n8n.workflow.success","message":"n8n.workflow.success","payload":{"userId":"aeec9612-50f4-4124-889e-83325fb5af8a","executionId":"2","success":true,"isManual":true,"mode":"manual","workflowId":"MiEBjJYxhUVm6sl3","workflowName":"New Lead Notification","projectId":"julyz3yhfTK4pMNd","projectName":"MC Cars "}} {"__type":"$$EventMessageConfirm","confirm":"0da331f2-66a0-45fd-b691-d4c28a01598c","ts":"2026-04-29T20:02:02.404+02:00","source":{"id":"0","name":"eventBus"}} +{"__type":"$$EventMessageAudit","id":"a0ac56d7-5dd2-4d92-ba16-9480f5d7be4e","ts":"2026-04-29T21:40:47.656+02:00","eventName":"n8n.audit.workflow.created","message":"n8n.audit.workflow.created","payload":{"userId":"aeec9612-50f4-4124-889e-83325fb5af8a","_email":"admin@mccars.local","_firstName":"MC","_lastName":"Cars","globalRole":"global:owner","workflowId":"5254P6wnxXA4bhWf","workflowName":"Lead Qualified → Payment Email"}} +{"__type":"$$EventMessageConfirm","confirm":"a0ac56d7-5dd2-4d92-ba16-9480f5d7be4e","ts":"2026-04-29T21:40:47.656+02:00","source":{"id":"0","name":"eventBus"}} +{"__type":"$$EventMessageAudit","id":"8b319e39-54c7-41d6-a1f6-4a28fcc9f340","ts":"2026-04-29T21:41:00.236+02:00","eventName":"n8n.audit.workflow.created","message":"n8n.audit.workflow.created","payload":{"userId":"aeec9612-50f4-4124-889e-83325fb5af8a","_email":"admin@mccars.local","_firstName":"MC","_lastName":"Cars","globalRole":"global:owner","workflowId":"40NOffvLouMXkVPO","workflowName":"Lead Qualified → Mietvertrag PDF"}} +{"__type":"$$EventMessageConfirm","confirm":"8b319e39-54c7-41d6-a1f6-4a28fcc9f340","ts":"2026-04-29T21:41:00.236+02:00","source":{"id":"0","name":"eventBus"}} diff --git a/docker-compose.local.yml b/docker-compose.local.yml index edc12fc..0e87692 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -24,6 +24,7 @@ services: - ./supabase/migrations/07-sales-orders.sql:/sql/07-sales-orders.sql:ro - ./supabase/migrations/08-backend-pricing-and-security.sql:/sql/08-backend-pricing-and-security.sql:ro - ./supabase/migrations/09-site-settings.sql:/sql/09-site-settings.sql:ro + - ./supabase/migrations/10-mietvertrag-workflow.sql:/sql/10-mietvertrag-workflow.sql:ro kong: volumes: diff --git a/docker-compose.yml b/docker-compose.yml index fa324aa..6bbf47c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -217,6 +217,7 @@ services: - /mnt/user/appdata/mc-cars/supabase/migrations/07-sales-orders.sql:/sql/07-sales-orders.sql:ro - /mnt/user/appdata/mc-cars/supabase/migrations/08-backend-pricing-and-security.sql:/sql/08-backend-pricing-and-security.sql:ro - /mnt/user/appdata/mc-cars/supabase/migrations/09-site-settings.sql:/sql/09-site-settings.sql:ro + - /mnt/user/appdata/mc-cars/supabase/migrations/10-mietvertrag-workflow.sql:/sql/10-mietvertrag-workflow.sql:ro entrypoint: ["sh","-c"] command: - | @@ -242,6 +243,7 @@ services: psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/07-sales-orders.sql psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/08-backend-pricing-and-security.sql psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/09-site-settings.sql + psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/10-mietvertrag-workflow.sql echo "post-init done." restart: "no" networks: [mccars] @@ -413,3 +415,19 @@ services: - "55590:5678" networks: [mccars] logging: { driver: json-file, options: { max-size: "10m", max-file: "3" } } + + # ------------------------------------------------------------------------- + # Gotenberg - headless document converter (DOCX → PDF) + # Used by n8n to generate Mietvertrag PDFs from DOCX templates. + # ------------------------------------------------------------------------- + gotenberg: + image: gotenberg/gotenberg:8 + container_name: mccars-gotenberg + restart: unless-stopped + command: + - "gotenberg" + - "--api-port=3000" + - "--api-timeout=60s" + - "--libreoffice-restart-after=10" + networks: [mccars] + logging: { driver: json-file, options: { max-size: "10m", max-file: "3" } } diff --git a/frontend/admin.html b/frontend/admin.html index 0b9da48..67decfb 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -253,6 +253,16 @@

JPG/PNG/WebP. Wird als Hintergrundbild im Hero-Bereich der Website angezeigt.

+
+
+ +

DOCX-Vorlage mit Platzhaltern. Wird bei Qualifizierung automatisch ausgefüllt und als PDF per E-Mail versendet.

+ +
diff --git a/frontend/admin.js b/frontend/admin.js index ea385fb..f37c104 100644 --- a/frontend/admin.js +++ b/frontend/admin.js @@ -1108,11 +1108,25 @@ orderDialog.addEventListener("close", onDialogClose); const heroPreview = document.querySelector("#heroPreview"); const heroImageInput = document.querySelector("#heroImageInput"); const heroFeedback = document.querySelector("#heroFeedback"); +const mietvertragStatus = document.querySelector("#mietvertragStatus"); +const mietvertragInput = document.querySelector("#mietvertragInput"); +const mietvertragFeedback = document.querySelector("#mietvertragFeedback"); async function renderSettings() { const { data } = await supabase.from("site_settings").select("value").eq("key", "hero_image_url").single(); const url = data?.value || "/images/ferrari-main-car.png"; heroPreview.style.backgroundImage = `url('${url}')`; + + // Mietvertrag template status + const { data: tplData } = await supabase.from("site_settings").select("value").eq("key", "mietvertrag_template_path").single(); + const tplPath = tplData?.value; + if (tplPath) { + mietvertragStatus.textContent = "✓ " + tplPath.split("/").pop(); + mietvertragStatus.style.color = "var(--success, green)"; + } else { + mietvertragStatus.textContent = t("adminMietvertragEmpty") || "Keine Vorlage hochgeladen."; + mietvertragStatus.style.color = "var(--muted)"; + } } heroImageInput.addEventListener("change", async () => { @@ -1145,4 +1159,32 @@ heroImageInput.addEventListener("change", async () => { } }); +mietvertragInput.addEventListener("change", async () => { + const file = mietvertragInput.files?.[0]; + if (!file) return; + mietvertragFeedback.className = "form-feedback"; + mietvertragFeedback.textContent = "Uploading..."; + try { + const path = `mietvertrag/vorlage.docx`; + const { error: upErr } = await supabase.storage + .from("document-templates") + .upload(path, file, { contentType: file.type, upsert: true }); + if (upErr) throw upErr; + + // Save path to site_settings + const { error: dbErr } = await supabase + .from("site_settings") + .upsert({ key: "mietvertrag_template_path", value: path, updated_at: new Date().toISOString() }, { onConflict: "key" }); + if (dbErr) throw dbErr; + + mietvertragStatus.textContent = "✓ " + file.name; + mietvertragStatus.style.color = "var(--success, green)"; + mietvertragFeedback.className = "form-feedback"; + mietvertragFeedback.textContent = "Gespeichert."; + } catch (err) { + mietvertragFeedback.className = "form-feedback error"; + mietvertragFeedback.textContent = err.message || String(err); + } +}); + bootstrap(); diff --git a/frontend/i18n.js b/frontend/i18n.js index 39973e8..95d4345 100644 --- a/frontend/i18n.js +++ b/frontend/i18n.js @@ -133,6 +133,9 @@ export const translations = { adminSettings: "Einstellungen", adminHeroImage: "Hauptbild (Hero-Bereich)", adminHeroImageHint: "JPG/PNG/WebP. Wird als Hintergrundbild im Hero-Bereich der Website angezeigt.", + adminMietvertragTemplate: "Mietvertrag-Vorlage (DOCX)", + adminMietvertragHint: "DOCX-Vorlage mit Platzhaltern. Wird bei Qualifizierung automatisch ausgefüllt und als PDF per E-Mail versendet.", + adminMietvertragEmpty: "Keine Vorlage hochgeladen.", adminNewVehicle: "Neues Fahrzeug", adminAllVehicles: "Alle Fahrzeuge", adminPhotoUpload: "Foto hochladen (JPG/PNG/WebP, max 50 MB)", @@ -364,6 +367,9 @@ export const translations = { adminSettings: "Settings", adminHeroImage: "Main Photo (Hero Section)", adminHeroImageHint: "JPG/PNG/WebP. Displayed as the background image in the hero section of the website.", + adminMietvertragTemplate: "Rental Contract Template (DOCX)", + adminMietvertragHint: "DOCX template with placeholders. Automatically filled and sent as PDF via email upon qualification.", + adminMietvertragEmpty: "No template uploaded.", adminNewVehicle: "New vehicle", adminAllVehicles: "All vehicles", adminPhotoUpload: "Upload photo (JPG/PNG/WebP, max 50 MB)", diff --git a/n8n/workflows/01-qualification-payment-email.json b/n8n/workflows/01-qualification-payment-email.json new file mode 100644 index 0000000..e37a687 --- /dev/null +++ b/n8n/workflows/01-qualification-payment-email.json @@ -0,0 +1,96 @@ +{ + "name": "Lead Qualified → Payment Email", + "nodes": [ + { + "parameters": { + "triggerOnNotify": true, + "channel": "lead_qualified", + "additionalFields": {} + }, + "id": "pg-trigger", + "name": "Postgres Trigger", + "type": "n8n-nodes-base.postgresTrigger", + "typeVersion": 1, + "position": [250, 300], + "credentials": { + "postgres": { + "id": "1", + "name": "MC Cars Postgres" + } + } + }, + { + "parameters": { + "operation": "executeQuery", + "query": "SELECT c.id, c.name, c.email, c.phone,\n so.order_number, so.total_eur, so.deposit_eur,\n so.date_from, so.date_to, so.vehicle_label,\n so.daily_subtotal, so.weekend_subtotal,\n so.subtotal_eur, so.vat_eur,\n so.total_days, so.weekday_count, so.weekend_day_count\nFROM public.customers c\nJOIN public.sales_orders so ON so.customer_id = c.id\nWHERE so.id = '{{ $json.sales_order_id }}'::uuid", + "additionalFields": {} + }, + "id": "fetch-order-data", + "name": "Fetch Order Data", + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.5, + "position": [470, 300], + "credentials": { + "postgres": { + "id": "1", + "name": "MC Cars Postgres" + } + } + }, + { + "parameters": { + "fromEmail": "info@mc-cars.at", + "toEmail": "={{ $json.email }}", + "subject": "MC Cars – Ihre Buchung {{ $json.order_number }} – Zahlungsanweisungen", + "emailType": "html", + "html": "
\n
\n

MC Cars

\n
\n
\n

Sehr geehrte/r {{ $json.name }},

\n

vielen Dank für Ihre Buchung! Hier sind Ihre Buchungsdetails und Zahlungsanweisungen:

\n \n \n \n \n \n \n \n \n \n \n
Bestellnummer:{{ $json.order_number }}
Fahrzeug:{{ $json.vehicle_label }}
Zeitraum:{{ $json.date_from }} – {{ $json.date_to }}
Tage gesamt:{{ $json.total_days }}
Netto:€{{ $json.subtotal_eur }}
MwSt (19%):€{{ $json.vat_eur }}
Gesamtbetrag:€{{ $json.total_eur }}
Kaution:€{{ $json.deposit_eur }}
\n\n

Zahlungsanweisungen

\n \n

1. Kaution (€{{ $json.deposit_eur }})

\n

Bitte überweisen Sie die Kaution auf folgendes Konto:

\n
\n

Empfänger: MC Cars GmbH

\n

IBAN: AT00 0000 0000 0000 0000

\n

BIC: BKAUATWW

\n

Verwendungszweck: Kaution {{ $json.order_number }}

\n
\n\n

2. Mietbetrag (€{{ $json.total_eur }})

\n

Den Mietbetrag können Sie bequem online bezahlen:

\n \n

Oder überweisen Sie auf dasselbe Konto mit Verwendungszweck: Miete {{ $json.order_number }}

\n\n
\n

Bei Fragen stehen wir Ihnen jederzeit zur Verfügung.

\n

Mit freundlichen Grüßen,
MC Cars GmbH
info@mc-cars.at
mc-cars.at

\n
\n
", + "options": {} + }, + "id": "send-email", + "name": "Send Payment Email", + "type": "n8n-nodes-base.emailSend", + "typeVersion": 2.1, + "position": [690, 300], + "credentials": { + "smtp": { + "id": "2", + "name": "MC Cars SMTP" + } + } + } + ], + "connections": { + "Postgres Trigger": { + "main": [ + [ + { + "node": "Fetch Order Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Order Data": { + "main": [ + [ + { + "node": "Send Payment Email", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + }, + "staticData": null, + "tags": [ + { + "name": "mc-cars" + } + ], + "triggerCount": 1 +} diff --git a/n8n/workflows/02-mietvertrag-pdf-email.json b/n8n/workflows/02-mietvertrag-pdf-email.json new file mode 100644 index 0000000..4c21752 --- /dev/null +++ b/n8n/workflows/02-mietvertrag-pdf-email.json @@ -0,0 +1,291 @@ +{ + "name": "Lead Qualified → Mietvertrag PDF", + "nodes": [ + { + "parameters": { + "triggerOnNotify": true, + "channel": "lead_qualified", + "additionalFields": {} + }, + "id": "pg-trigger-mv", + "name": "Postgres Trigger", + "type": "n8n-nodes-base.postgresTrigger", + "typeVersion": 1, + "position": [250, 300], + "credentials": { + "postgres": { + "id": "1", + "name": "MC Cars Postgres" + } + } + }, + { + "parameters": { + "operation": "executeQuery", + "query": "SELECT value FROM public.site_settings WHERE key = 'mietvertrag_template_path'", + "additionalFields": {} + }, + "id": "check-template", + "name": "Check Template Exists", + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.5, + "position": [470, 300], + "credentials": { + "postgres": { + "id": "1", + "name": "MC Cars Postgres" + } + } + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "cond1", + "leftValue": "={{ $json.value }}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "notEmpty" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-template-exists", + "name": "Template Exists?", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [670, 300] + }, + { + "parameters": { + "operation": "executeQuery", + "query": "SELECT c.name, c.email, c.phone,\n so.order_number, so.total_eur, so.deposit_eur,\n so.date_from, so.date_to, so.vehicle_label,\n so.daily_subtotal, so.weekend_subtotal,\n so.subtotal_eur, so.vat_eur,\n so.total_days, so.weekday_count, so.weekend_day_count,\n to_char(so.date_from, 'DD.MM.YYYY') as date_from_de,\n to_char(so.date_to, 'DD.MM.YYYY') as date_to_de,\n to_char(now(), 'DD.MM.YYYY') as today_de\nFROM public.customers c\nJOIN public.sales_orders so ON so.customer_id = c.id\nWHERE so.id = '{{ $('Postgres Trigger').item.json.sales_order_id }}'::uuid", + "additionalFields": {} + }, + "id": "fetch-full-data", + "name": "Fetch Full Order+Customer", + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.5, + "position": [890, 200], + "credentials": { + "postgres": { + "id": "1", + "name": "MC Cars Postgres" + } + } + }, + { + "parameters": { + "url": "=http://kong:8000/storage/v1/object/document-templates/{{ $('Check Template Exists').item.json.value }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "apikey", + "value": "={{ $env.SERVICE_ROLE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU' }}" + }, + { + "name": "Authorization", + "value": "=Bearer {{ $env.SERVICE_ROLE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU' }}" + } + ] + }, + "options": { + "response": { + "response": { + "responseFormat": "file" + } + } + } + }, + "id": "download-template", + "name": "Download DOCX Template", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [1110, 200] + }, + { + "parameters": { + "jsCode": "// Fill DOCX template placeholders using simple text replacement.\n// The DOCX is a ZIP containing XML. We replace {{placeholder}} markers\n// in the document.xml with actual values from the order data.\n\nconst JSZip = require('jszip');\n\nconst binaryData = await this.helpers.getBinaryDataBuffer(0, 'data');\nconst orderData = $('Fetch Full Order+Customer').first().json;\n\n// Placeholders map\nconst placeholders = {\n '{{KUNDE_NAME}}': orderData.name || '',\n '{{KUNDE_EMAIL}}': orderData.email || '',\n '{{KUNDE_TELEFON}}': orderData.phone || '',\n '{{BESTELLNUMMER}}': orderData.order_number || '',\n '{{FAHRZEUG}}': orderData.vehicle_label || '',\n '{{DATUM_VON}}': orderData.date_from_de || '',\n '{{DATUM_BIS}}': orderData.date_to_de || '',\n '{{TAGE_GESAMT}}': String(orderData.total_days || 0),\n '{{WOCHENTAGE}}': String(orderData.weekday_count || 0),\n '{{WOCHENENDTAGE}}': String(orderData.weekend_day_count || 0),\n '{{NETTO}}': String(orderData.subtotal_eur || 0),\n '{{MWST}}': String(orderData.vat_eur || 0),\n '{{GESAMT}}': String(orderData.total_eur || 0),\n '{{KAUTION}}': String(orderData.deposit_eur || 0),\n '{{TAGESSATZ}}': String(orderData.daily_subtotal || 0),\n '{{WOCHENENDZUSCHLAG}}': String(orderData.weekend_subtotal || 0),\n '{{DATUM_HEUTE}}': orderData.today_de || '',\n};\n\nconst zip = await JSZip.loadAsync(binaryData);\n\n// Process all XML files in the docx\nconst xmlFiles = Object.keys(zip.files).filter(f => f.endsWith('.xml'));\n\nfor (const xmlFile of xmlFiles) {\n let content = await zip.file(xmlFile).async('string');\n for (const [placeholder, value] of Object.entries(placeholders)) {\n // Handle split placeholders in XML (Word splits text into runs)\n // Simple approach: replace in the raw XML\n const escaped = placeholder.replace(/[{}]/g, c => `\\\\${c}`);\n content = content.split(placeholder).join(value);\n }\n zip.file(xmlFile, content);\n}\n\nconst filledDocx = await zip.generateAsync({ type: 'nodebuffer' });\n\nreturn [{\n json: { filename: `Mietvertrag_${orderData.order_number}.docx` },\n binary: {\n data: await this.helpers.prepareBinaryData(filledDocx, `Mietvertrag_${orderData.order_number}.docx`, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')\n }\n}];" + }, + "id": "fill-template", + "name": "Fill DOCX Template", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1330, 200] + }, + { + "parameters": { + "url": "http://gotenberg:3000/forms/libreoffice/convert", + "method": "POST", + "sendBody": true, + "contentType": "multipart-form-data", + "bodyParameters": { + "parameters": [ + { + "parameterType": "formBinaryData", + "name": "files", + "inputDataFieldName": "data" + } + ] + }, + "options": { + "response": { + "response": { + "responseFormat": "file" + } + } + } + }, + "id": "convert-to-pdf", + "name": "Convert to PDF (Gotenberg)", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [1550, 200] + }, + { + "parameters": { + "jsCode": "// Rename the binary output to have the correct PDF filename\nconst orderData = $('Fetch Full Order+Customer').first().json;\nconst binaryData = await this.helpers.getBinaryDataBuffer(0, 'data');\n\nreturn [{\n json: { filename: `Mietvertrag_${orderData.order_number}.pdf`, email: orderData.email, name: orderData.name, order_number: orderData.order_number },\n binary: {\n data: await this.helpers.prepareBinaryData(binaryData, `Mietvertrag_${orderData.order_number}.pdf`, 'application/pdf')\n }\n}];" + }, + "id": "prepare-pdf", + "name": "Prepare PDF Attachment", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1770, 200] + }, + { + "parameters": { + "fromEmail": "info@mc-cars.at", + "toEmail": "={{ $json.email }}", + "subject": "MC Cars – Ihr Mietvertrag {{ $json.order_number }}", + "emailType": "html", + "html": "
\n
\n

MC Cars

\n
\n
\n

Sehr geehrte/r {{ $json.name }},

\n

anbei finden Sie Ihren Mietvertrag für die Bestellung {{ $json.order_number }}.

\n

Bitte prüfen Sie die Angaben und bringen Sie den unterschriebenen Vertrag zur Fahrzeugübergabe mit.

\n
\n

Bei Fragen stehen wir Ihnen jederzeit zur Verfügung.

\n

Mit freundlichen Grüßen,
MC Cars GmbH
info@mc-cars.at
mc-cars.at

\n
\n
", + "options": { + "attachments": "data" + } + }, + "id": "send-mietvertrag", + "name": "Send Mietvertrag Email", + "type": "n8n-nodes-base.emailSend", + "typeVersion": 2.1, + "position": [1990, 200], + "credentials": { + "smtp": { + "id": "2", + "name": "MC Cars SMTP" + } + } + } + ], + "connections": { + "Postgres Trigger": { + "main": [ + [ + { + "node": "Check Template Exists", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check Template Exists": { + "main": [ + [ + { + "node": "Template Exists?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Template Exists?": { + "main": [ + [ + { + "node": "Fetch Full Order+Customer", + "type": "main", + "index": 0 + } + ], + [] + ] + }, + "Fetch Full Order+Customer": { + "main": [ + [ + { + "node": "Download DOCX Template", + "type": "main", + "index": 0 + } + ] + ] + }, + "Download DOCX Template": { + "main": [ + [ + { + "node": "Fill DOCX Template", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fill DOCX Template": { + "main": [ + [ + { + "node": "Convert to PDF (Gotenberg)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Convert to PDF (Gotenberg)": { + "main": [ + [ + { + "node": "Prepare PDF Attachment", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare PDF Attachment": { + "main": [ + [ + { + "node": "Send Mietvertrag Email", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + }, + "staticData": null, + "tags": [ + { + "name": "mc-cars" + } + ], + "triggerCount": 1 +} diff --git a/n8n/workflows/README.md b/n8n/workflows/README.md new file mode 100644 index 0000000..7439b2a --- /dev/null +++ b/n8n/workflows/README.md @@ -0,0 +1,88 @@ +# n8n Workflows – MC Cars + +This folder contains exportable n8n workflow definitions for the MC Cars qualification automation. + +## Workflows + +### 01 – Qualification Payment Email +**Trigger:** Postgres `NOTIFY` on channel `lead_qualified` (fires when `qualify_lead()` creates a sales order). + +**Flow:** +1. Receives notification with `sales_order_id`, `customer_id`, etc. +2. Fetches full order + customer data from Postgres. +3. Sends HTML email to customer with: + - Booking summary (vehicle, dates, pricing) + - Kaution bank transfer instructions + - Payment link for the rental amount + +### 02 – Mietvertrag PDF Email +**Trigger:** Same `lead_qualified` Postgres notification. + +**Flow:** +1. Checks if `mietvertrag_template_path` is set in `site_settings`. +2. If no template → workflow stops (no error). +3. If template exists: + - Fetches customer + sales order data + - Downloads DOCX template from `document-templates` storage bucket + - Fills placeholders using JSZip (in a Code node) + - Converts filled DOCX to PDF via Gotenberg + - Sends PDF as email attachment to customer + +## Setup Instructions + +### 1. Create Postgres credential in n8n +- **Name:** `MC Cars Postgres` +- **Host:** `db` +- **Port:** `5432` +- **Database:** `postgres` +- **User:** `postgres` +- **Password:** (value of `POSTGRES_PASSWORD` from `.env`) + +### 2. Create SMTP credential in n8n +- **Name:** `MC Cars SMTP` +- **Host:** your SMTP server (e.g. `smtp.mailgun.org`, `mail.mc-cars.at`) +- **Port:** `587` (TLS) or `465` (SSL) +- **User:** your SMTP username +- **Password:** your SMTP password +- **From:** `info@mc-cars.at` + +### 3. Import workflows +1. Open n8n at http://localhost:55590 +2. Go to **Workflows** → **Import from file** +3. Import `01-qualification-payment-email.json` +4. Import `02-mietvertrag-pdf-email.json` +5. Open each workflow → assign the credentials created above → **Activate** + +### 4. Upload Mietvertrag template (optional) +1. Open Admin panel → **Einstellungen** tab +2. Upload a DOCX file in the "Mietvertrag-Vorlage" section +3. The template should contain these placeholders: + +| Placeholder | Replaced with | +|---|---| +| `{{KUNDE_NAME}}` | Customer full name | +| `{{KUNDE_EMAIL}}` | Customer email | +| `{{KUNDE_TELEFON}}` | Customer phone | +| `{{BESTELLNUMMER}}` | Sales order number (e.g. SO-2026-0001) | +| `{{FAHRZEUG}}` | Vehicle label (e.g. "Ferrari 488 GTB") | +| `{{DATUM_VON}}` | Rental start date (DD.MM.YYYY) | +| `{{DATUM_BIS}}` | Rental end date (DD.MM.YYYY) | +| `{{TAGE_GESAMT}}` | Total rental days | +| `{{WOCHENTAGE}}` | Number of weekdays | +| `{{WOCHENENDTAGE}}` | Number of weekend days | +| `{{TAGESSATZ}}` | Weekday daily subtotal | +| `{{WOCHENENDZUSCHLAG}}` | Weekend surcharge subtotal | +| `{{NETTO}}` | Net amount (excl. VAT) | +| `{{MWST}}` | VAT amount (19%) | +| `{{GESAMT}}` | Total amount (incl. VAT) | +| `{{KAUTION}}` | Deposit amount | +| `{{DATUM_HEUTE}}` | Today's date (DD.MM.YYYY) | + +## Dependencies + +- **Gotenberg** (docker service `gotenberg`) — converts DOCX → PDF via LibreOffice +- **n8n** needs the `jszip` npm package (pre-installed in n8n Docker image) + +## Domain + +All email links and sender addresses use `mc-cars.at`. diff --git a/supabase/migrations/10-mietvertrag-workflow.sql b/supabase/migrations/10-mietvertrag-workflow.sql new file mode 100644 index 0000000..eae96f3 --- /dev/null +++ b/supabase/migrations/10-mietvertrag-workflow.sql @@ -0,0 +1,66 @@ +-- 10-mietvertrag-workflow.sql +-- Document templates bucket, mietvertrag setting, and qualification webhook trigger. + +-- 1. Storage bucket for admin-uploaded document templates (private, admin-only) +insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +values ( + 'document-templates', + 'document-templates', + false, + 20971520, + array['application/vnd.openxmlformats-officedocument.wordprocessingml.document'] +) +on conflict (id) do nothing; + +-- RLS: only authenticated (admin) can read/write document-templates +drop policy if exists "templates_admin_select" on storage.objects; +create policy "templates_admin_select" + on storage.objects for select to authenticated + using (bucket_id = 'document-templates'); + +drop policy if exists "templates_admin_insert" on storage.objects; +create policy "templates_admin_insert" + on storage.objects for insert to authenticated + with check (bucket_id = 'document-templates'); + +drop policy if exists "templates_admin_update" on storage.objects; +create policy "templates_admin_update" + on storage.objects for update to authenticated + using (bucket_id = 'document-templates'); + +-- 2. Site setting for Mietvertrag template (empty on startup) +insert into public.site_settings (key, value) +values ('mietvertrag_template_path', '') +on conflict (key) do nothing; + +-- 3. Function to notify n8n when a lead is qualified (new sales order created) +-- n8n listens on the 'lead_qualified' channel via Postgres trigger. +create or replace function public.notify_lead_qualified() +returns trigger +language plpgsql +security definer +as $$ +begin + perform pg_notify('lead_qualified', json_build_object( + 'sales_order_id', NEW.id, + 'customer_id', NEW.customer_id, + 'lead_id', NEW.lead_id, + 'order_number', NEW.order_number, + 'total_eur', NEW.total_eur, + 'deposit_eur', NEW.deposit_eur, + 'date_from', NEW.date_from, + 'date_to', NEW.date_to, + 'vehicle_label', NEW.vehicle_label + )::text); + return NEW; +end; +$$; + +-- Trigger on sales_orders insert (fires when qualify_lead creates the order) +drop trigger if exists trg_notify_lead_qualified on public.sales_orders; +create trigger trg_notify_lead_qualified + after insert on public.sales_orders + for each row + execute function public.notify_lead_qualified(); + +notify pgrst, 'reload schema';