diff --git a/compose.yaml b/compose.yaml index 957d4ca..f560f54 100644 --- a/compose.yaml +++ b/compose.yaml @@ -24,6 +24,7 @@ services: n8n: image: docker.n8n.io/n8nio/n8n restart: always + hostname: n8n.eks-intec.de ports: - "127.0.0.1:5678:5678" labels: @@ -51,6 +52,7 @@ services: - WEBHOOK_URL=https://${SUBDOMAIN}.${DOMAIN_NAME}/ - GENERIC_TIMEZONE=${GENERIC_TIMEZONE} - TZ=${GENERIC_TIMEZONE} + - NODE_TLS_REJECT_UNAUTHORIZED=0 volumes: - n8n_data:/home/node/.n8n - ./local-files:/files diff --git a/freescout-templates/auto_reply.blade.php b/freescout-templates/auto_reply.blade.php new file mode 100644 index 0000000..0339232 --- /dev/null +++ b/freescout-templates/auto_reply.blade.php @@ -0,0 +1,68 @@ + + + + + + + + +{{-- Outer background table --}} + + +
+ +{{-- Email card --}} + + + {{-- HEADER BAR --}} + + + + + {{-- CONTENT --}} + + + + + {{-- FOOTER --}} + + + + + + + @if (\App\Option::get('email_branding')) + + + + @endif + +
+ {{ $mailbox->name }} + Automatische Antwort +
+
+
+ {!! $auto_reply_message !!} +
+
+
+
+
+ + {{ $mailbox->name }} • {{ $mailbox->email }} + +
+ + {!! __('Support powered by :app_name — Free open source help desk & shared mailbox', ['app_name' => ''.\Config::get('app.name').'']) !!} + +
{{-- /email card --}} +
{{-- /outer --}} + +{{ \MailHelper::getMessageMarker($headers['Message-ID']) }} + + + diff --git a/freescout-templates/reply_fancy.blade.php b/freescout-templates/reply_fancy.blade.php new file mode 100644 index 0000000..ae8bb4d --- /dev/null +++ b/freescout-templates/reply_fancy.blade.php @@ -0,0 +1,143 @@ + + + + + + + +@php + $reply_separator = \MailHelper::getHashedReplySeparator($headers['Message-ID']); + $is_rtl = \Helper::isLocaleRtl(); + $is_forwarded = !empty($threads[0]) ? $threads[0]->isForwarded() : false; +@endphp + +{{-- Outer background table --}} + + +
+ +{{-- Email card --}} + + + {{-- HEADER BAR --}} + + + + + {{-- CONTENT with reply separator --}} + + + + + {{-- FOOTER --}} + + + + + + + @if (\App\Option::get('email_branding')) + + + + @endif + +
+ + + + + +
+ {{ $mailbox->name }} + + + Ticket #{{ $conversation->number }} + +
+
+
+ + @foreach ($threads as $thread) + @if ($loop->index == 1) + {{-- Gmail quoted-message marker --}} +
+ @endif + + @if (!$loop->first) + {{-- Quoted thread header --}} +
+ + + + + +
+ + {{ $thread->getFromName($mailbox) }}@if ($is_forwarded && $thread->from) <{{ $thread->from }}>@endif + + @if ($thread->getCcArray()) + +  · Cc: {{ implode(', ', $thread->getCcArray()) }} + + @endif + + + {{ App\Customer::dateFormat($thread->created_at, 'M j, H:i') }} + +
+
+ @endif + + {{-- Thread body --}} +
+
+ @if ($thread->source_via == App\Thread::PERSON_USER && $mailbox->before_reply && $loop->first) + {{ $mailbox->before_reply }}

+ @endif + {!! $thread->getCleanBody() !!} + @action('reply_email.before_signature', $thread, $loop, $threads, $conversation, $mailbox, $threads_count) + @if ($thread->source_via == App\Thread::PERSON_USER && \Eventy::filter('reply_email.include_signature', true, $thread)) +
{!! $conversation->getSignatureProcessed(['thread' => $thread]) !!} + @endif + @action('reply_email.after_signature', $thread, $loop, $threads, $conversation, $mailbox, $threads_count) +

+
+
+ + @if (!$loop->last) +
+ @endif + + @endforeach + + {{-- Tracking pixel and message marker (hidden) --}} +
+ @if (\App\Option::get('open_tracking')) + + @endif + ‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌{{ \MailHelper::getMessageMarker($headers['Message-ID']) }} +
+ +
{{-- /reply_separator --}} +
+
+
+ + {{ $mailbox->name }} • {{ $mailbox->email }} + +
+ + {!! __('Support powered by :app_name — Free open source help desk & shared mailbox', ['app_name' => ''.\Config::get('app.name').'']) !!} + +
{{-- /email card --}} +
{{-- /outer --}} + + + diff --git a/n8n-workflows/workflow-a-http.json b/n8n-workflows/workflow-a-http.json index 99b77d4..64542bc 100644 --- a/n8n-workflows/workflow-a-http.json +++ b/n8n-workflows/workflow-a-http.json @@ -30,42 +30,25 @@ } }, { - "id": "uuid-extract-data", - "name": "Extract Conversation Data", - "type": "n8n-nodes-base.set", - "typeVersion": 3, + "id": "uuid-split-out", + "name": "Split Array into Items", + "type": "n8n-nodes-base.splitOut", + "typeVersion": 1, "position": [650, 200], "parameters": { - "options": {}, - "assignments": { - "assignments": [ - { - "name": "ticket_id", - "value": "={{ $json.id }}", - "type": "number" - }, - { - "name": "ticket_number", - "value": "={{ $json.number }}", - "type": "number" - }, - { - "name": "subject", - "value": "={{ $json.subject }}", - "type": "string" - }, - { - "name": "problem_text", - "value": "={{ ($json.threads_text || 'No description provided').substring(0, 2000) }}", - "type": "string" - }, - { - "name": "customer_email", - "value": "={{ $json.customer_email }}", - "type": "string" - } - ] - } + "fieldToSplitOut": "data", + "options": {} + } + }, + { + "id": "uuid-extract-data", + "name": "Extract Conversation Data", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [850, 200], + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const item = $input.item.json;\n// HTML-Tags entfernen damit die AI lesbaren Text bekommt\nconst rawText = item.threads_text || 'Keine Beschreibung vorhanden';\nconst plainText = rawText\n .replace(/<[^>]+>/g, ' ')\n .replace(/ /g, ' ')\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/"/g, '\"')\n .replace(/\\s+/g, ' ')\n .trim()\n .substring(0, 2000);\nreturn { json: {\n ticket_id: item.id,\n ticket_number: item.number,\n subject: item.subject,\n customer_email: item.customer_email,\n problem_text: plainText\n}};" } }, { @@ -73,7 +56,7 @@ "name": "LiteLLM AI Analysis", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4, - "position": [850, 200], + "position": [1050, 200], "parameters": { "url": "http://llm.eks-ai.apps.asgard.eks-lnx.fft-it.de/v1/chat/completions", "method": "POST", @@ -82,60 +65,54 @@ }, "sendBody": true, "specifyBody": "json", - "jsonBody": "{\"model\":\"gpt-oss_120b_128k-gpu\",\"messages\":[{\"role\":\"system\",\"content\":\"Du bist ein IT-Support-Assistent. Analysiere das folgende IT-Support-Ticket und gib eine strukturierte JSON-Antwort mit folgenden Feldern: kategorie (z.B. Hardware, Software, Netzwerk, Zugriff), lösung_typ (BARAMUNDI_JOB, AUTOMATISCHE_ANTWORT, oder ESKALATION), vertrauen (Dezimal zwischen 0.0 und 1.0 - wie sicher bist du bei dieser Lösung), baramundi_job (Name des Jobs falls BARAMUNDI_JOB), antwort_text (Die Antwort an den Nutzer), begründung (Kurze Erklärung deiner Analyse)\"},{\"role\":\"user\",\"content\":\"Ticket-Nummer: {{$json.ticket_number}}\\nBetreff: {{$json.subject}}\\nProblembeschreibung:\\n{{$json.problem_text}}\\n\\nBitte antworte NUR mit gültiger JSON in dieser Struktur: {\\\"kategorie\\\": \\\"...\\\", \\\"lösung_typ\\\": \\\"...\\\", \\\"vertrauen\\\": 0.75, \\\"baramundi_job\\\": \\\"...\\\", \\\"antwort_text\\\": \\\"...\\\", \\\"begründung\\\": \\\"...\\\"}\"}],\"temperature\":0.7,\"max_tokens\":1000}" + "jsonBody": "={{ JSON.stringify({model: 'gpt-oss_120b_128k-gpu', messages: [{role: 'system', content: 'Du bist ein IT-Support-Assistent. Analysiere das folgende IT-Support-Ticket und gib eine strukturierte JSON-Antwort mit folgenden Feldern: kategorie (z.B. Hardware, Software, Netzwerk, Zugriff), lösung_typ (BARAMUNDI_JOB, AUTOMATISCHE_ANTWORT, oder ESKALATION), vertrauen (Dezimal zwischen 0.0 und 1.0 - wie sicher bist du bei dieser Lösung), baramundi_job (Name des Jobs falls BARAMUNDI_JOB), antwort_text (Die Antwort an den Nutzer), begründung (Kurze Erklärung deiner Analyse)'}, {role: 'user', content: 'Ticket-Nummer: ' + $json.ticket_number + '\\nBetreff: ' + $json.subject + '\\nProblembeschreibung:\\n' + $json.problem_text + '\\n\\nBitte antworte NUR mit gültiger JSON in dieser Struktur: {\"kategorie\": \"...\", \"lösung_typ\": \"...\", \"vertrauen\": 0.75, \"baramundi_job\": \"...\", \"antwort_text\": \"...\", \"begründung\": \"...\"}'}], temperature: 0.7, max_tokens: 1000}) }}" } }, { "id": "uuid-parse-response", "name": "Parse AI Response", - "type": "n8n-nodes-base.set", - "typeVersion": 3, - "position": [1050, 200], + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1250, 200], "parameters": { - "options": {}, - "assignments": { - "assignments": [ - { - "name": "response_text", - "value": "={{ $json.choices?.[0]?.message?.content || '{}' }}", - "type": "string" - }, - { - "name": "ai_response", - "value": "={{ (function() { try { return JSON.parse($json.response_text); } catch(e) { return {kategorie: 'unknown', lösung_typ: 'ESKALATION', vertrauen: 0.3}; } })() }}", - "type": "object" - }, - { - "name": "vertrauen", - "value": "={{ $json.ai_response?.vertrauen || 0.3 }}", - "type": "number" - } - ] - } + "mode": "runOnceForEachItem", + "jsCode": "const content = $input.item.json.choices[0].message.content;\nconst extractData = $('Extract Conversation Data').item.json;\nconst ticketId = extractData.ticket_id !== undefined ? extractData.ticket_id : extractData.id;\nlet vertrauen = 0.1;\nlet loesung_typ = 'UNBEKANNT';\nlet kategorie = '';\nlet antwort_text = '';\nlet baramundi_job = '';\ntry {\n const parsed = JSON.parse(content);\n vertrauen = typeof parsed.vertrauen === 'number' ? parsed.vertrauen : 0.1;\n loesung_typ = parsed['lösung_typ'] || parsed.loesung_typ || 'UNBEKANNT';\n kategorie = parsed.kategorie || '';\n antwort_text = parsed.antwort_text || '';\n baramundi_job = parsed.baramundi_job || '';\n} catch(e) { vertrauen = 0.1; }\n// Human-readable for Freescout textarea\nconst lines = [loesung_typ + ' | Vertrauen: ' + vertrauen + ' | Kategorie: ' + kategorie];\nif (baramundi_job) lines.push('Baramundi-Job: ' + baramundi_job);\nlines.push('---');\nlines.push(antwort_text);\nconst display_text = lines.join(' | ');\n// SQL-safe: Quotes escapen, Zeilenumbrüche als ¶ (Pilcrow) erhalten damit\n// Workflow B die Struktur der KI-Antwort wiederherstellen kann.\nconst ai_content_sql = display_text.replace(/'/g, \"''\").replace(/\\r/g, '').replace(/\\n/g, '¶');\nconst ai_json_sql = content.replace(/'/g, \"''\").replace(/[\\n\\r]/g, ' ');\nreturn { json: { vertrauen, ticket_id: ticketId, ai_content: content, ai_content_sql, ai_json_sql } };" } }, { "id": "uuid-check-confidence", "name": "Check Confidence >= 0.6", - "type": "n8n-nodes-base.switch", - "typeVersion": 1, - "position": [1250, 200], + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [1450, 200], "parameters": { - "options": [ - { - "condition": "numberGreaterThanOrEqual", - "value1": "={{ $json.vertrauen }}", - "value2": 0.6 - } - ] + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose" + }, + "conditions": [ + { + "id": "cond-confidence", + "leftValue": "={{ $json.vertrauen }}", + "rightValue": 0.6, + "operator": { + "type": "number", + "operation": "gte" + } + } + ], + "combinator": "and" + } } }, { - "id": "uuid-save-to-db", - "name": "Save Suggestion to Freescout DB", + "id": "uuid-save-ai-suggestion", + "name": "Save AI Suggestion (field 6)", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4, - "position": [1450, 100], + "position": [1650, 100], "parameters": { "url": "http://host.docker.internal:4000/query/freescout", "method": "POST", @@ -144,7 +121,41 @@ }, "sendBody": true, "specifyBody": "json", - "jsonBody": "{\"query\":\"INSERT INTO conversation_custom_field (conversation_id, custom_field_id, value) VALUES ({{$json.ticket_id}}, 6, '{{$json.ai_response | json.stringify}}') ON DUPLICATE KEY UPDATE value = VALUES(value); INSERT INTO conversation_custom_field (conversation_id, custom_field_id, value) VALUES ({{$json.ticket_id}}, 7, 'PENDING') ON DUPLICATE KEY UPDATE value = VALUES(value); INSERT INTO conversation_custom_field (conversation_id, custom_field_id, value) VALUES ({{$json.ticket_id}}, 8, '1') ON DUPLICATE KEY UPDATE value = VALUES(value);\"}" + "jsonBody": "={{ JSON.stringify({query: \"INSERT INTO conversation_custom_field (conversation_id, custom_field_id, value) VALUES (\" + $json.ticket_id + \", 6, '\" + $json.ai_content_sql + \"') ON DUPLICATE KEY UPDATE value = VALUES(value)\"}) }}" + } + }, + { + "id": "uuid-save-status-pending", + "name": "Save Status PENDING (field 7)", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [1650, 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: \"INSERT INTO conversation_custom_field (conversation_id, custom_field_id, value) VALUES (\" + $('Parse AI Response').item.json.ticket_id + \", 7, '0') ON DUPLICATE KEY UPDATE value = VALUES(value)\"}) }}" + } + }, + { + "id": "uuid-save-processed-flag", + "name": "Save Processed Flag (field 8)", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [1650, 300], + "parameters": { + "url": "http://host.docker.internal:4000/query/freescout", + "method": "POST", + "headers": { + "Content-Type": "application/json" + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({query: \"INSERT INTO conversation_custom_field (conversation_id, custom_field_id, value) VALUES (\" + $('Parse AI Response').item.json.ticket_id + \", 8, '1') ON DUPLICATE KEY UPDATE value = VALUES(value)\"}) }}" } }, { @@ -152,19 +163,22 @@ "name": "Skip - Low Confidence", "type": "n8n-nodes-base.set", "typeVersion": 3, - "position": [1450, 350], + "position": [1650, 350], "parameters": { + "mode": "manual", "options": {}, "assignments": { "assignments": [ { + "id": "assign-skipped", "name": "skipped", "value": true, "type": "boolean" }, { + "id": "assign-reason", "name": "reason", - "value": "Confidence {{$json.vertrauen}} < 0.6", + "value": "={{ 'Confidence ' + $json.vertrauen + ' < 0.6' }}", "type": "string" } ] @@ -175,68 +189,48 @@ "connections": { "Trigger": { "main": [ - [ - { - "node": "Get Unprocessed Conversations", - "index": 0 - } - ] + [{"node": "Get Unprocessed Conversations", "index": 0}] ] }, "Get Unprocessed Conversations": { "main": [ - [ - { - "node": "Extract Conversation Data", - "index": 0 - } - ] + [{"node": "Split Array into Items", "index": 0}] + ] + }, + "Split Array into Items": { + "main": [ + [{"node": "Extract Conversation Data", "index": 0}] ] }, "Extract Conversation Data": { "main": [ - [ - { - "node": "LiteLLM AI Analysis", - "index": 0 - } - ] + [{"node": "LiteLLM AI Analysis", "index": 0}] ] }, "LiteLLM AI Analysis": { "main": [ - [ - { - "node": "Parse AI Response", - "index": 0 - } - ] + [{"node": "Parse AI Response", "index": 0}] ] }, "Parse AI Response": { "main": [ - [ - { - "node": "Check Confidence >= 0.6", - "index": 0 - } - ] + [{"node": "Check Confidence >= 0.6", "index": 0}] ] }, "Check Confidence >= 0.6": { "main": [ - [ - { - "node": "Save Suggestion to Freescout DB", - "index": 0 - } - ], - [ - { - "node": "Skip - Low Confidence", - "index": 0 - } - ] + [{"node": "Save AI Suggestion (field 6)", "index": 0}], + [{"node": "Skip - Low Confidence", "index": 0}] + ] + }, + "Save AI Suggestion (field 6)": { + "main": [ + [{"node": "Save Status PENDING (field 7)", "index": 0}] + ] + }, + "Save Status PENDING (field 7)": { + "main": [ + [{"node": "Save Processed Flag (field 8)", "index": 0}] ] } }, diff --git a/n8n-workflows/workflow-b-http.json b/n8n-workflows/workflow-b-http.json index dbdb4c3..b794530 100644 --- a/n8n-workflows/workflow-b-http.json +++ b/n8n-workflows/workflow-b-http.json @@ -7,7 +7,7 @@ "name": "Trigger", "type": "n8n-nodes-base.cron", "typeVersion": 1, - "position": [250, 200], + "position": [250, 300], "parameters": { "cronExpression": "*/2 * * * *" } @@ -17,97 +17,78 @@ "name": "Get Approved Conversations", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4, - "position": [450, 200], + "position": [450, 300], "parameters": { "url": "http://host.docker.internal:4000/query/freescout", "method": "POST", - "headers": { - "Content-Type": "application/json" - }, + "headers": { "Content-Type": "application/json" }, "sendBody": true, "specifyBody": "json", - "jsonBody": "{\"query\":\"SELECT c.id, c.number, c.subject, c.customer_email, ccf.value as ai_suggestion FROM conversations c JOIN conversation_custom_field ccf ON c.id = ccf.conversation_id WHERE ccf.custom_field_id = 7 AND ccf.value = 'APPROVED' LIMIT 10\"}" + "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-split-approved", - "name": "Split Results", - "type": "n8n-nodes-base.splitInBatches", - "typeVersion": 3, - "position": [650, 200], + "id": "uuid-check-empty", + "name": "Any Approved?", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [650, 300], "parameters": { - "batchSize": 1, - "options": {} - } - }, - { - "id": "uuid-extract-approved", - "name": "Extract & Parse Suggestion", - "type": "n8n-nodes-base.set", - "typeVersion": 3, - "position": [850, 200], - "parameters": { - "options": {}, - "assignments": { - "assignments": [ + "conditions": { + "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "loose" }, + "conditions": [ { - "name": "ticket_id", - "value": "={{ $json.id }}", - "type": "number" - }, - { - "name": "ticket_number", - "value": "={{ $json.number }}", - "type": "number" - }, - { - "name": "subject", - "value": "={{ $json.subject }}", - "type": "string" - }, - { - "name": "customer_email", - "value": "={{ $json.customer_email }}", - "type": "string" - }, - { - "name": "ai_suggestion_raw", - "value": "={{ typeof $json.ai_suggestion === 'string' ? $json.ai_suggestion : JSON.stringify($json.ai_suggestion) }}", - "type": "string" - }, - { - "name": "ai_suggestion", - "value": "={{ typeof $json.ai_suggestion === 'string' ? JSON.parse($json.ai_suggestion) : $json.ai_suggestion }}", - "type": "object" - }, - { - "name": "solution_type", - "value": "={{ $json.ai_suggestion.lösung_typ || 'UNKNOWN' }}", - "type": "string" + "id": "cond-has-data", + "leftValue": "={{ $json.data.length }}", + "rightValue": 0, + "operator": { "type": "number", "operation": "gt" } } - ] + ], + "combinator": "and" } } }, { - "id": "uuid-decide-solution", - "name": "Decide Solution Type", - "type": "n8n-nodes-base.switch", + "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": { - "options": [ - { - "condition": "equal", - "value1": "={{ $json.solution_type }}", - "value2": "BARAMUNDI_JOB" - }, - { - "condition": "equal", - "value1": "={{ $json.solution_type }}", - "value2": "AUTOMATISCHE_ANTWORT" - } - ] + "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" + } } }, { @@ -115,7 +96,7 @@ "name": "Execute Baramundi Job", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4, - "position": [1250, 50], + "position": [1450, 100], "parameters": { "url": "https://baramundi-api.example.com/api/jobs", "method": "POST", @@ -125,77 +106,106 @@ }, "sendBody": true, "specifyBody": "json", - "jsonBody": "{\"job_name\":\"{{$json.ai_suggestion.baramundi_job}}\",\"ticket_id\":{{$json.ticket_id}},\"target_system\":\"IT\",\"description\":\"{{$json.subject}}\"}" + "jsonBody": "={{ JSON.stringify({job_name: $json.baramundi_job, ticket_id: $json.ticket_id, description: $json.subject}) }}" } }, { - "id": "uuid-send-email", - "name": "Send Email Reply", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4, - "position": [1250, 150], + "id": "uuid-is-auto-reply", + "name": "Is Auto Reply?", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [1450, 300], "parameters": { - "url": "http://host.docker.internal:4000/query/freescout", - "method": "POST", - "headers": { - "Content-Type": "application/json" - }, - "sendBody": true, - "specifyBody": "json", - "jsonBody": "{\"query\":\"INSERT INTO threads (conversation_id, customer_id, user_id, type, status, body, created_at, updated_at) VALUES ({{$json.ticket_id}}, (SELECT customer_id FROM conversations WHERE id = {{$json.ticket_id}} LIMIT 1), NULL, 'customer', 'active', '{{$json.ai_suggestion.antwort_text | replace(\\\"'\\\", \\\"''\\\")}}', NOW(), NOW())\"}" - } - }, - { - "id": "uuid-mark-escalation", - "name": "Mark for Manual Review", - "type": "n8n-nodes-base.set", - "typeVersion": 3, - "position": [1250, 270], - "parameters": { - "options": {}, - "assignments": { - "assignments": [ + "conditions": { + "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "loose" }, + "conditions": [ { - "name": "action", - "value": "Manual escalation required", - "type": "string" + "id": "cond-autoreply", + "leftValue": "={{ $json.loesung_typ }}", + "rightValue": "AUTOMATISCHE_ANTWORT", + "operator": { "type": "string", "operation": "equals" } } - ] + ], + "combinator": "and" } } }, { - "id": "uuid-update-status", - "name": "Update Status to EXECUTED", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4, + "id": "uuid-prepare-email", + "name": "Prepare Email Body", + "type": "n8n-nodes-base.code", + "typeVersion": 2, "position": [1450, 200], "parameters": { - "url": "http://host.docker.internal:4000/query/freescout", - "method": "POST", - "headers": { - "Content-Type": "application/json" - }, - "sendBody": true, - "specifyBody": "json", - "jsonBody": "{\"query\":\"UPDATE conversation_custom_field SET value = 'EXECUTED' WHERE conversation_id = {{$json.ticket_id}} AND custom_field_id = 7\"}" + "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-trigger-workflow-c", - "name": "Trigger Workflow C (KB Update)", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4, + "id": "uuid-send-reply", + "name": "Send Email Reply", + "type": "n8n-nodes-base.emailSend", + "typeVersion": 1, "position": [1650, 200], "parameters": { - "url": "https://n8n.fft-it.de/webhook/workflow-c", + "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" - }, + "headers": { "Content-Type": "application/json" }, "sendBody": true, "specifyBody": "json", - "jsonBody": "{\"ticket_id\":{{$json.ticket_id}},\"subject\":\"{{$json.subject}}\",\"problem\":\"{{$json.subject}}\",\"solution\":\"{{$json.ai_suggestion.antwort_text}}\",\"category\":\"{{$json.ai_suggestion.kategorie}}\",\"solution_type\":\"{{$json.solution_type}}\"}" + "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\"}) }}" } }, { @@ -203,131 +213,79 @@ "name": "Log to PostgreSQL", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4, - "position": [1850, 200], + "position": [2450, 200], "parameters": { "url": "http://host.docker.internal:4000/query/audit", "method": "POST", - "headers": { - "Content-Type": "application/json" - }, + "headers": { "Content-Type": "application/json" }, "sendBody": true, "specifyBody": "json", - "jsonBody": "{\"query\":\"INSERT INTO workflow_executions (workflow_name, ticket_id, status, execution_time_ms, created_at) VALUES ('Workflow B - Approval Execution', {{$json.ticket_id}}, 'SUCCESS', 0, NOW())\"}" + "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 - } - ] - ] + "main": [[{"node": "Get Approved Conversations", "index": 0}]] }, "Get Approved Conversations": { + "main": [[{"node": "Any Approved?", "index": 0}]] + }, + "Any Approved?": { "main": [ - [ - { - "node": "Split Results", - "index": 0 - } - ] + [{"node": "Split into Items", "index": 0}], + [{"node": "No Approved Tickets", "index": 0}] ] }, - "Split Results": { + "Split into Items": { + "main": [[{"node": "Parse Suggestion", "index": 0}]] + }, + "Parse Suggestion": { + "main": [[{"node": "Is Baramundi Job?", "index": 0}]] + }, + "Is Baramundi Job?": { "main": [ - [ - { - "node": "Extract & Parse Suggestion", - "index": 0 - } - ] + [{"node": "Execute Baramundi Job", "index": 0}], + [{"node": "Is Auto Reply?", "index": 0}] ] }, - "Extract & Parse Suggestion": { + "Is Auto Reply?": { "main": [ - [ - { - "node": "Decide Solution Type", - "index": 0 - } - ] + [{"node": "Prepare Email Body", "index": 0}], + [{"node": "Mark Escalation", "index": 0}] ] }, - "Decide Solution Type": { - "main": [ - [ - { - "node": "Execute Baramundi Job", - "index": 0 - } - ], - [ - { - "node": "Send Email Reply", - "index": 0 - } - ], - [ - { - "node": "Mark for Manual Review", - "index": 0 - } - ] - ] + "Prepare Email Body": { + "main": [[{"node": "Send Email Reply", "index": 0}]] }, "Execute Baramundi Job": { - "main": [ - [ - { - "node": "Update Status to EXECUTED", - "index": 0 - } - ] - ] + "main": [[{"node": "Update Status to EXECUTED", "index": 0}]] }, "Send Email Reply": { - "main": [ - [ - { - "node": "Update Status to EXECUTED", - "index": 0 - } - ] - ] + "main": [[{"node": "Log Reply to Freescout", "index": 0}]] }, - "Mark for Manual Review": { - "main": [ - [ - { - "node": "Update Status to EXECUTED", - "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": "Trigger Workflow C (KB Update)", - "index": 0 - } - ] - ] - }, - "Trigger Workflow C (KB Update)": { - "main": [ - [ - { - "node": "Log to PostgreSQL", - "index": 0 - } - ] - ] + "main": [[{"node": "Log to PostgreSQL", "index": 0}]] } }, "active": false,