{ "name": "Workflow B - Approval & Execution (HTTP)", "description": "Poll for approved AI suggestions and execute them (Baramundi jobs or email replies)", "nodes": [ { "id": "uuid-trigger-b", "name": "Trigger", "type": "n8n-nodes-base.cron", "typeVersion": 1, "position": [250, 300], "parameters": { "cronExpression": "*/2 * * * *" } }, { "id": "uuid-get-approved", "name": "Get Approved Conversations", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4, "position": [450, 300], "parameters": { "url": "http://host.docker.internal:4000/query/freescout", "method": "POST", "headers": { "Content-Type": "application/json" }, "sendBody": true, "specifyBody": "json", "jsonBody": "{\"query\":\"SELECT c.id as ticket_id, c.number as ticket_number, c.subject, c.customer_email, ccf6.value as ai_suggestion_raw, ccf7.value as approval_status FROM conversations c JOIN conversation_custom_field ccf7 ON c.id = ccf7.conversation_id AND ccf7.custom_field_id = 7 LEFT JOIN conversation_custom_field ccf6 ON c.id = ccf6.conversation_id AND ccf6.custom_field_id = 6 WHERE ccf7.value = '1' LIMIT 10\"}" } }, { "id": "uuid-check-empty", "name": "Any Approved?", "type": "n8n-nodes-base.if", "typeVersion": 2, "position": [650, 300], "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "loose" }, "conditions": [ { "id": "cond-has-data", "leftValue": "={{ $json.data.length }}", "rightValue": 0, "operator": { "type": "number", "operation": "gt" } } ], "combinator": "and" } } }, { "id": "uuid-split-approved", "name": "Split into Items", "type": "n8n-nodes-base.splitOut", "typeVersion": 1, "position": [850, 200], "parameters": { "fieldToSplitOut": "data", "options": {} } }, { "id": "uuid-parse-suggestion", "name": "Parse Suggestion", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [1050, 200], "parameters": { "mode": "runOnceForEachItem", "jsCode": "const item = $input.item.json;\nconst ticketId = item.ticket_id;\nconst raw = item.ai_suggestion_raw || '';\n\nlet loesung_typ = 'ESKALATION';\nlet baramundi_job = '';\nlet antwort_text = '';\n\n// Lösung-Typ aus dem ersten Segment extrahieren\nconst firstPart = raw.split('|')[0].trim();\nif (firstPart === 'BARAMUNDI_JOB' || firstPart === 'AUTOMATISCHE_ANTWORT' || firstPart === 'ESKALATION') {\n loesung_typ = firstPart;\n}\n\n// Baramundi-Job Name extrahieren\nconst jobMatch = raw.match(/Baramundi-Job:\\s*([^|\\n]+)/);\nif (jobMatch) baramundi_job = jobMatch[1].trim();\n\n// Antworttext nach '--- |' extrahieren\nconst sepIdx = raw.indexOf('--- |');\nif (sepIdx !== -1) {\n antwort_text = raw.substring(sepIdx + 5).trim();\n}\n\n// Fallback: gesamter raw-Text wenn antwort_text leer\nif (!antwort_text && loesung_typ === 'AUTOMATISCHE_ANTWORT') {\n // Versuche nach '---' ohne Pipe zu suchen\n const sepIdx2 = raw.indexOf('---');\n if (sepIdx2 !== -1) {\n antwort_text = raw.substring(sepIdx2 + 3).replace(/^\\s*\\|\\s*/, '').trim();\n }\n // Letzter Fallback: alle Segmente nach dem 4. Pipe-Zeichen\n if (!antwort_text) {\n const parts = raw.split('|');\n if (parts.length > 3) {\n antwort_text = parts.slice(3).join('|').trim();\n }\n }\n}\n\nreturn { json: {\n ticket_id: ticketId,\n ticket_number: item.ticket_number,\n subject: item.subject,\n customer_email: item.customer_email,\n loesung_typ,\n baramundi_job,\n antwort_text,\n raw_suggestion: raw\n}};" } }, { "id": "uuid-is-baramundi", "name": "Is Baramundi Job?", "type": "n8n-nodes-base.if", "typeVersion": 2, "position": [1250, 200], "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "loose" }, "conditions": [ { "id": "cond-baramundi", "leftValue": "={{ $json.loesung_typ }}", "rightValue": "BARAMUNDI_JOB", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" } } }, { "id": "uuid-execute-baramundi", "name": "Execute Baramundi Job", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4, "position": [1450, 100], "parameters": { "url": "https://baramundi-api.example.com/api/jobs", "method": "POST", "headers": { "Content-Type": "application/json", "Authorization": "Bearer YOUR_BARAMUNDI_TOKEN" }, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({job_name: $json.baramundi_job, ticket_id: $json.ticket_id, description: $json.subject}) }}" } }, { "id": "uuid-is-auto-reply", "name": "Is Auto Reply?", "type": "n8n-nodes-base.if", "typeVersion": 2, "position": [1450, 300], "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "loose" }, "conditions": [ { "id": "cond-autoreply", "leftValue": "={{ $json.loesung_typ }}", "rightValue": "AUTOMATISCHE_ANTWORT", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" } } }, { "id": "uuid-prepare-email", "name": "Prepare Email Body", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [1450, 200], "parameters": { "mode": "runOnceForEachItem", "jsCode": "const p = $('Parse Suggestion').item.json;\nconst rawBody = p.antwort_text || p.raw_suggestion || 'Bitte wenden Sie sich an den IT-Support.';\n\n// 1. Markdown entfernen + Struktur wiederherstellen\nlet clean = rawBody\n .replace(/¶/g, '\\n') // ¶-Platzhalter → echte Newlines (neue Tickets)\n .replace(/\\*\\*/g, '')\n .replace(/\\*/g, '')\n .replace(/_{2}/g, '')\n .replace(/ \\| /g, '\\n') // Pipe-Trennzeichen → Zeilenumbrüche\n // Fallback für alte Tickets (flacher Text ohne ¶): Struktur per Regex\n .replace(/ (\\d{1,2}\\.) ([A-ZÄÖÜ])/g, '\\n\\n$1 $2') // \" 1. Windows\" → Absatz\n .replace(/\\. - ([A-ZÄÖÜ])/g, '.\\n- $1') // \". - Öffnen\" → Aufzählung\n .replace(/ (Mit freundlichen)/g, '\\n\\n$1') // Grußformel\n .replace(/ (Sollten Sie)/g, '\\n\\n$1') // Abschlussatz\n .replace(/[ \\t]{2,}/g, ' ') // horizontale Mehrfach-Spaces normalisieren\n .trim();\n\n// 2. Text → HTML konvertieren\n// Absätze: doppelte Newlines →

\n// Nummerierten Listen erkennen und als

  1. ausgeben\nconst paragraphs = clean.split(/\\n{2,}/);\nconst htmlParts = paragraphs.map(para => {\n const lines = para.split('\\n');\n // Prüfen ob alle nicht-leeren Zeilen nummerierte Listeneinträge sind\n const listItems = lines.filter(l => l.trim()).every(l => /^\\d+\\.\\s/.test(l.trim()));\n if (listItems) {\n const items = lines.filter(l => l.trim()).map(l => {\n const text = l.trim().replace(/^\\d+\\.\\s*/, '');\n return '
  2. ' + text + '
  3. ';\n }).join('');\n return '
      ' + items + '
    ';\n }\n // Einzelne Zeilenumbrüche innerhalb eines Absatzes →
    \n return '

    ' + lines.join('
    ') + '

    ';\n}).join('');\n\n// 3. Plain-Text für Freescout-Logging (Newlines erhalten)\nconst plainText = clean;\n\n// 4. HTML-E-Mail Template (Freescout-Stil: blau-grau, professionell)\nconst emailHtml = `
    EKS InTec IT-SupportTicket #${p.ticket_number}
    ${htmlParts}
    Diese Antwort wurde automatisch durch das IT-Support-System erstellt.
    Bei weiteren Fragen antworten Sie einfach auf diese E-Mail.
    EKS InTec GmbH • IT-Support • it@eks-intec.de
    `;\n\nreturn { json: {\n ticket_id: p.ticket_id,\n ticket_number: p.ticket_number,\n subject: p.subject,\n customer_email: p.customer_email,\n loesung_typ: p.loesung_typ,\n baramundi_job: p.baramundi_job,\n antwort_text: p.antwort_text,\n raw_suggestion: p.raw_suggestion,\n email_body: plainText,\n email_html: emailHtml,\n email_to: p.customer_email,\n email_subject: 'Re: [#' + p.ticket_number + '] ' + p.subject\n}};" } }, { "id": "uuid-send-reply", "name": "Send Email Reply", "type": "n8n-nodes-base.emailSend", "typeVersion": 1, "position": [1650, 200], "parameters": { "fromEmail": "it@eks-intec.de", "toEmail": "={{ $json.email_to }}", "subject": "={{ $json.email_subject }}", "html": "={{ $json.email_html }}", "text": "={{ $json.email_body }}", "options": {} } }, { "id": "uuid-log-freescout-reply", "name": "Log Reply to Freescout", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [1850, 200], "parameters": { "mode": "runOnceForEachItem", "jsCode": "const parsed = $('Parse Suggestion').item.json;\nconst emailData = $('Prepare Email Body').item.json;\nconst ticketId = parsed.ticket_id;\n// Vollständigen E-Mail-Text in Freescout eintragen (als Agent Reply sichtbar)\n// type=2 = Agent Reply (Freescout: 1=Customer, 2=Message/Agent, 3=Note, 4=LineItem)\n// type=0 existiert NICHT -> Thread.php:420 crasht\n// Zeilenumbrüche ->
    damit Freescout sie korrekt anzeigt, Quotes escapen\nconst rawBody = (emailData.email_body || 'Automatische Antwort gesendet.')\n .replace(/'/g, \"''\")\n .replace(/\\n/g, '
    \\n');\n// created_at = MAX(existierende Threads) + 1 Sekunde, damit unser Reply\n// immer als neuester Thread erscheint (Freescout zeigt neueste oben).\n// Hintergrund: Kunden-Emails haben created_at aus dem Mail-Header-Datum,\n// das häufig in der Zukunft liegt relativ zum Zeitpunkt unserer Verarbeitung.\nconst query = \"INSERT INTO threads (conversation_id, type, status, state, body, created_by_user_id, user_id, customer_id, source_via, source_type, cc, bcc, created_at, updated_at) SELECT \" + ticketId + \", 2, 1, 2, '\" + rawBody + \"', 1, 1, customer_id, 1, 1, '[]', '[]', GREATEST(NOW(), IFNULL((SELECT MAX(t2.created_at) FROM threads t2 WHERE t2.conversation_id = \" + ticketId + \"), NOW())) + INTERVAL 1 SECOND, NOW() FROM conversations WHERE id = \" + ticketId;\nreturn { json: { query, ticket_id: ticketId } };" } }, { "id": "uuid-write-freescout-thread", "name": "Write Thread to Freescout DB", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4, "position": [2050, 200], "parameters": { "url": "http://host.docker.internal:4000/query/freescout", "method": "POST", "headers": { "Content-Type": "application/json" }, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ query: $json.query }) }}" } }, { "id": "uuid-mark-escalation", "name": "Mark Escalation", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [1650, 400], "parameters": { "mode": "runOnceForEachItem", "jsCode": "return { json: { ...$input.item.json, action: 'ESKALATION - manuelle Bearbeitung erforderlich' } };" } }, { "id": "uuid-update-executed", "name": "Update Status to EXECUTED", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4, "position": [2250, 200], "parameters": { "url": "http://host.docker.internal:4000/query/freescout", "method": "POST", "headers": { "Content-Type": "application/json" }, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({query: \"UPDATE conversation_custom_field SET value = '3' WHERE conversation_id = \" + $('Parse Suggestion').item.json.ticket_id + \" AND custom_field_id = 7\"}) }}" } }, { "id": "uuid-log-audit", "name": "Log to PostgreSQL", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4, "position": [2450, 200], "parameters": { "url": "http://host.docker.internal:4000/query/audit", "method": "POST", "headers": { "Content-Type": "application/json" }, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({query: \"INSERT INTO workflow_executions (workflow_name, ticket_id, status, execution_time_ms, created_at) VALUES ('Workflow B - Approval Execution', \" + $('Parse Suggestion').item.json.ticket_id + \", 'SUCCESS', 0, NOW())\"}) }}" } }, { "id": "uuid-no-approved", "name": "No Approved Tickets", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [850, 400], "parameters": { "mode": "runOnceForEachItem", "jsCode": "return { json: { status: 'no_approved_tickets', timestamp: new Date().toISOString() } };" } } ], "connections": { "Trigger": { "main": [[{"node": "Get Approved Conversations", "index": 0}]] }, "Get Approved Conversations": { "main": [[{"node": "Any Approved?", "index": 0}]] }, "Any Approved?": { "main": [ [{"node": "Split into Items", "index": 0}], [{"node": "No Approved Tickets", "index": 0}] ] }, "Split into Items": { "main": [[{"node": "Parse Suggestion", "index": 0}]] }, "Parse Suggestion": { "main": [[{"node": "Is Baramundi Job?", "index": 0}]] }, "Is Baramundi Job?": { "main": [ [{"node": "Execute Baramundi Job", "index": 0}], [{"node": "Is Auto Reply?", "index": 0}] ] }, "Is Auto Reply?": { "main": [ [{"node": "Prepare Email Body", "index": 0}], [{"node": "Mark Escalation", "index": 0}] ] }, "Prepare Email Body": { "main": [[{"node": "Send Email Reply", "index": 0}]] }, "Execute Baramundi Job": { "main": [[{"node": "Update Status to EXECUTED", "index": 0}]] }, "Send Email Reply": { "main": [[{"node": "Log Reply to Freescout", "index": 0}]] }, "Log Reply to Freescout": { "main": [[{"node": "Write Thread to Freescout DB", "index": 0}]] }, "Write Thread to Freescout DB": { "main": [[{"node": "Update Status to EXECUTED", "index": 0}]] }, "Mark Escalation": { "main": [[{"node": "Update Status to EXECUTED", "index": 0}]] }, "Update Status to EXECUTED": { "main": [[{"node": "Log to PostgreSQL", "index": 0}]] } }, "active": false, "settings": { "errorHandler": "continueOnError" } }