From bbd5bbab9b5d88398b0f5e978cfc52458caefbc5 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 27 Oct 2025 17:40:43 +0100 Subject: [PATCH] Added Helpdesk --- src/index.ts | 5 + .../helpdesk/helpdesk.contact.service.ts | 38 +++ .../helpdesk/helpdesk.conversation.service.ts | 81 +++++ .../helpdesk/helpdesk.message.service.ts | 58 ++++ src/routes/helpdesk.inbound.ts | 136 ++++++++ src/routes/helpdesk.ts | 306 ++++++++++++++++++ 6 files changed, 624 insertions(+) create mode 100644 src/modules/helpdesk/helpdesk.contact.service.ts create mode 100644 src/modules/helpdesk/helpdesk.conversation.service.ts create mode 100644 src/modules/helpdesk/helpdesk.message.service.ts create mode 100644 src/routes/helpdesk.inbound.ts create mode 100644 src/routes/helpdesk.ts diff --git a/src/index.ts b/src/index.ts index 7a435d9..11e739b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,8 @@ import bankingRoutes from "./routes/banking"; import exportRoutes from "./routes/exports" import emailAsUserRoutes from "./routes/emailAsUser"; import authProfilesRoutes from "./routes/profiles"; +import helpdeskRoutes from "./routes/helpdesk"; +import helpdeskInboundRoutes from "./routes/helpdesk.inbound"; import {sendMail} from "./utils/mailer"; import {loadSecrets, secrets} from "./utils/secrets"; @@ -62,6 +64,8 @@ async function main() { await app.register(authRoutes); await app.register(healthRoutes); + await app.register(helpdeskInboundRoutes); + //Geschützte Routes @@ -82,6 +86,7 @@ async function main() { await subApp.register(exportRoutes); await subApp.register(emailAsUserRoutes); await subApp.register(authProfilesRoutes); + await subApp.register(helpdeskRoutes); },{prefix: "/api"}) diff --git a/src/modules/helpdesk/helpdesk.contact.service.ts b/src/modules/helpdesk/helpdesk.contact.service.ts new file mode 100644 index 0000000..f8337df --- /dev/null +++ b/src/modules/helpdesk/helpdesk.contact.service.ts @@ -0,0 +1,38 @@ +// modules/helpdesk/helpdesk.contact.service.ts +import { FastifyInstance } from 'fastify' + +export async function getOrCreateContact( + server: FastifyInstance, + tenant_id: number, + { email, phone, display_name, customer_id, contact_id }: { email?: string; phone?: string; display_name?: string; customer_id?: number; contact_id?: number } +) { + if (!email && !phone) throw new Error('Contact must have at least an email or phone') + + // Bestehenden Kontakt prüfen + const { data: existing, error: findError } = await server.supabase + .from('helpdesk_contacts') + .select('*') + .eq('tenant_id', tenant_id) + .or(`email.eq.${email || ''},phone.eq.${phone || ''}`) + .maybeSingle() + + if (findError) throw findError + if (existing) return existing + + // Anlegen + const { data: created, error: insertError } = await server.supabase + .from('helpdesk_contacts') + .insert({ + tenant_id, + email, + phone, + display_name, + customer_id, + contact_id + }) + .select() + .single() + + if (insertError) throw insertError + return created +} diff --git a/src/modules/helpdesk/helpdesk.conversation.service.ts b/src/modules/helpdesk/helpdesk.conversation.service.ts new file mode 100644 index 0000000..9c5ff9f --- /dev/null +++ b/src/modules/helpdesk/helpdesk.conversation.service.ts @@ -0,0 +1,81 @@ +// modules/helpdesk/helpdesk.conversation.service.ts +import { FastifyInstance } from 'fastify' +import { getOrCreateContact } from './helpdesk.contact.service.js' + +export async function createConversation( + server: FastifyInstance, + { + tenant_id, + contact, + channel_instance_id, + subject, + customer_id = null, + contact_person_id = null + }: { + tenant_id: number + contact: { email?: string; phone?: string; display_name?: string } + channel_instance_id: string + subject?: string, + customer_id?: number, + contact_person_id?: number + } +) { + const contactRecord = await getOrCreateContact(server, tenant_id, contact) + + const { data, error } = await server.supabase + .from('helpdesk_conversations') + .insert({ + tenant_id, + contact_id: contactRecord.id, + channel_instance_id, + subject: subject || null, + status: 'open', + created_at: new Date().toISOString(), + customer_id, + contact_person_id + }) + .select() + .single() + + if (error) throw error + return data +} + +export async function getConversations( + server: FastifyInstance, + tenant_id: number, + opts?: { status?: string; limit?: number } +) { + const { status, limit = 50 } = opts || {} + + let query = server.supabase.from('helpdesk_conversations').select('*, customer_id(*)').eq('tenant_id', tenant_id) + + if (status) query = query.eq('status', status) + query = query.order('last_message_at', { ascending: false }).limit(limit) + + const { data, error } = await query + if (error) throw error + return { + ...data, + customer: data.customer_id + } +} + +export async function updateConversationStatus( + server: FastifyInstance, + conversation_id: string, + status: string +) { + const valid = ['open', 'in_progress', 'waiting_for_customer', 'answered', 'closed'] + if (!valid.includes(status)) throw new Error('Invalid status') + + const { data, error } = await server.supabase + .from('helpdesk_conversations') + .update({ status }) + .eq('id', conversation_id) + .select() + .single() + + if (error) throw error + return data +} diff --git a/src/modules/helpdesk/helpdesk.message.service.ts b/src/modules/helpdesk/helpdesk.message.service.ts new file mode 100644 index 0000000..71f0c4c --- /dev/null +++ b/src/modules/helpdesk/helpdesk.message.service.ts @@ -0,0 +1,58 @@ +// modules/helpdesk/helpdesk.message.service.ts +import { FastifyInstance } from 'fastify' + +export async function addMessage( + server: FastifyInstance, + { + tenant_id, + conversation_id, + author_user_id = null, + direction = 'incoming', + payload, + raw_meta = null, + }: { + tenant_id: number + conversation_id: string + author_user_id?: string | null + direction?: 'incoming' | 'outgoing' | 'internal' | 'system' + payload: any + raw_meta?: any + } +) { + if (!payload?.text) throw new Error('Message payload requires text content') + + const { data: message, error } = await server.supabase + .from('helpdesk_messages') + .insert({ + tenant_id, + conversation_id, + author_user_id, + direction, + payload, + raw_meta, + created_at: new Date().toISOString(), + }) + .select() + .single() + + if (error) throw error + + // Letzte Nachricht aktualisieren + await server.supabase + .from('helpdesk_conversations') + .update({ last_message_at: new Date().toISOString() }) + .eq('id', conversation_id) + + return message +} + +export async function getMessages(server: FastifyInstance, conversation_id: string) { + const { data, error } = await server.supabase + .from('helpdesk_messages') + .select('*') + .eq('conversation_id', conversation_id) + .order('created_at', { ascending: true }) + + if (error) throw error + return data +} diff --git a/src/routes/helpdesk.inbound.ts b/src/routes/helpdesk.inbound.ts new file mode 100644 index 0000000..96752f7 --- /dev/null +++ b/src/routes/helpdesk.inbound.ts @@ -0,0 +1,136 @@ +// modules/helpdesk/helpdesk.inbound.routes.ts +import { FastifyPluginAsync } from 'fastify' +import { createConversation } from '../modules/helpdesk/helpdesk.conversation.service.js' +import { addMessage } from '../modules/helpdesk/helpdesk.message.service.js' +import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js' + +/** + * Öffentliche Route zum Empfang eingehender Kontaktformular-Nachrichten. + * Authentifizierung: über `public_token` aus helpdesk_channel_instances + */ + +function extractDomain(email) { + if (!email) return null + const parts = email.split("@") + return parts.length === 2 ? parts[1].toLowerCase() : null +} + +async function findCustomerOrContactByEmailOrDomain(server,fromMail, tenantId) { + const sender = fromMail + const senderDomain = extractDomain(sender) + if (!senderDomain) return null + + + // 1️⃣ Direkter Match über contacts + const { data: contactMatch } = await server.supabase + .from("contacts") + .select("id, customer") + .eq("email", sender) + .eq("tenant", tenantId) + .maybeSingle() + + if (contactMatch?.customer_id) return { + customer: contactMatch.customer, + contact: contactMatch.id + } + + // 2️⃣ Kunden laden, bei denen E-Mail oder Rechnungsmail passt + const { data: customers, error } = await server.supabase + .from("customers") + .select("id, infoData") + .eq("tenant", tenantId) + + if (error) { + console.error(`[Helpdesk] Fehler beim Laden der Kunden:`, error.message) + return null + } + + // 3️⃣ Durch Kunden iterieren und prüfen + for (const c of customers || []) { + const info = c.infoData || {} + const email = info.email?.toLowerCase() + const invoiceEmail = info.invoiceEmail?.toLowerCase() + + const emailDomain = extractDomain(email) + const invoiceDomain = extractDomain(invoiceEmail) + + // exakter Match oder Domain-Match + if ( + sender === email || + sender === invoiceEmail || + senderDomain === emailDomain || + senderDomain === invoiceDomain + ) { + return {customer: c.id, contact:null} + } + } + + return null +} + +const helpdeskInboundRoutes: FastifyPluginAsync = async (server) => { + // Öffentliche POST-Route + server.post('/helpdesk/inbound/:public_token', async (req, res) => { + const { public_token } = req.params + const { email, phone, display_name, subject, message } = req.body + + if (!message) { + return res.status(400).send({ error: 'Message content required' }) + } + + // 1️⃣ Kanalinstanz anhand des Tokens ermitteln + const { data: channel, error: channelError } = await server.supabase + .from('helpdesk_channel_instances') + .select('*') + .eq('public_token', public_token) + .single() + + if (channelError || !channel) { + return res.status(404).send({ error: 'Invalid channel token' }) + } + + const tenant_id = channel.tenant_id + const channel_instance_id = channel.id + + // @ts-ignore + const {customer, contact: contactPerson} = await findCustomerOrContactByEmailOrDomain(server,email, tenant_id ) + + + // 2️⃣ Kontakt finden oder anlegen + const contact = await getOrCreateContact(server, tenant_id, { + email, + phone, + display_name, + customer_id: customer, + contact_id: contactPerson, + }) + + // 3️⃣ Konversation erstellen + const conversation = await createConversation(server, { + tenant_id, + contact, + channel_instance_id, + subject: subject ?? 'Kontaktformular Anfrage', + customer_id: customer, + contact_person_id: contactPerson + }) + + // 4️⃣ Erste Nachricht hinzufügen + await addMessage(server, { + tenant_id, + conversation_id: conversation.id, + direction: 'incoming', + payload: { type: 'text', text: message }, + raw_meta: { source: 'contact_form' }, + }) + + // (optional) Auto-Antwort oder Event hier ergänzen + + return res.status(201).send({ + success: true, + conversation_id: conversation.id, + }) + }) +} + +export default helpdeskInboundRoutes diff --git a/src/routes/helpdesk.ts b/src/routes/helpdesk.ts new file mode 100644 index 0000000..0e4d302 --- /dev/null +++ b/src/routes/helpdesk.ts @@ -0,0 +1,306 @@ +// modules/helpdesk/helpdesk.routes.ts +import { FastifyPluginAsync } from 'fastify' +import { createConversation, getConversations, updateConversationStatus } from '../modules/helpdesk/helpdesk.conversation.service.js' +import { addMessage, getMessages } from '../modules/helpdesk/helpdesk.message.service.js' +import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js' +import {decrypt, encrypt} from "../utils/crypt"; +import nodemailer from "nodemailer" + +const helpdeskRoutes: FastifyPluginAsync = async (server) => { + // 📩 1. Liste aller Konversationen + server.get('/helpdesk/conversations', async (req, res) => { + const tenant_id = req.user?.tenant_id + if (!tenant_id) return res.status(401).send({ error: 'Unauthorized' }) + + const { status } = req.query + const conversations = await getConversations(server, tenant_id, { status }) + return res.send(conversations) + }) + + // 🆕 2. Neue Konversation erstellen + server.post('/helpdesk/conversations', async (req, res) => { + const tenant_id = req.user?.tenant_id + if (!tenant_id) return res.status(401).send({ error: 'Unauthorized' }) + + const { contact, channel_instance_id, subject, message } = req.body + if (!contact || !channel_instance_id) { + return res.status(400).send({ error: 'Missing contact or channel_instance_id' }) + } + + // 1. Konversation erstellen + const conversation = await createConversation(server, { + tenant_id, + contact, + channel_instance_id, + subject, + }) + + // 2. Falls erste Nachricht vorhanden → hinzufügen + if (message) { + await addMessage(server, { + tenant_id, + conversation_id: conversation.id, + direction: 'incoming', + payload: { type: 'text', text: message }, + }) + } + + return res.status(201).send(conversation) + }) + + // 🧭 3. Einzelne Konversation abrufen + server.get('/helpdesk/conversations/:id', async (req, res) => { + const tenant_id = req.user?.tenant_id + const conversation_id = req.params.id + + const { data, error } = await server.supabase + .from('helpdesk_conversations') + .select('*, helpdesk_contacts(*)') + .eq('tenant_id', tenant_id) + .eq('id', conversation_id) + .single() + + if (error) return res.status(404).send({ error: 'Conversation not found' }) + return res.send(data) + }) + + // 🔄 4. Konversation Status ändern + server.patch('/helpdesk/conversations/:id/status', async (req, res) => { + const conversation_id = req.params.id + const { status } = req.body + + const updated = await updateConversationStatus(server, conversation_id, status) + return res.send(updated) + }) + + // 💬 5. Nachrichten abrufen + server.get('/helpdesk/conversations/:id/messages', async (req, res) => { + const conversation_id = req.params.id + const messages = await getMessages(server, conversation_id) + return res.send(messages) + }) + + // 💌 6. Nachricht hinzufügen (z. B. Antwort eines Agents) + server.post('/helpdesk/conversations/:id/messages', async (req, res) => { + console.log(req.user) + const tenant_id = req.user?.tenant_id + const author_user_id = req.user?.user_id + const conversation_id = req.params.id + const { text } = req.body + + if (!text) return res.status(400).send({ error: 'Missing message text' }) + + const message = await addMessage(server, { + tenant_id, + conversation_id, + author_user_id, + direction: 'outgoing', + payload: { type: 'text', text }, + }) + + return res.status(201).send(message) + }) + + // 👤 7. Kontakt suchen oder anlegen + server.post('/helpdesk/contacts', async (req, res) => { + const tenant_id = req.user?.tenant_id + const { email, phone, display_name } = req.body + + const contact = await getOrCreateContact(server, tenant_id, { email, phone, display_name }) + return res.status(201).send(contact) + }) + + server.post("/helpdesk/channels", { + schema: { + body: { + type: "object", + required: ["type_id", "name", "config"], + properties: { + type_id: { type: "string" }, + name: { type: "string" }, + config: { type: "object" }, + is_active: { type: "boolean", default: true }, + }, + }, + }, + handler: async (req, reply) => { + const { type_id, name, config, is_active = true } = req.body + + // 🔒 Tenant aus Auth-Context + const tenant_id = req.user?.tenant_id + if (!tenant_id) { + return reply.status(401).send({ error: "Kein Tenant im Benutzerkontext gefunden." }) + } + + if (type_id !== "email") { + return reply.status(400).send({ error: "Nur Typ 'email' wird aktuell unterstützt." }) + } + + try { + const safeConfig = { ...config } + + // 🔐 IMAP-Daten verschlüsseln + if (safeConfig.imap) { + if (safeConfig.imap.host) + safeConfig.imap.host = encrypt(safeConfig.imap.host) + if (safeConfig.imap.user) + safeConfig.imap.user = encrypt(safeConfig.imap.user) + if (safeConfig.imap.pass) + safeConfig.imap.pass = encrypt(safeConfig.imap.pass) + } + + // 🔐 SMTP-Daten verschlüsseln + if (safeConfig.smtp) { + if (safeConfig.smtp.host) + safeConfig.smtp.host = encrypt(safeConfig.smtp.host) + if (safeConfig.smtp.user) + safeConfig.smtp.user = encrypt(safeConfig.smtp.user) + if (safeConfig.smtp.pass) + safeConfig.smtp.pass = encrypt(safeConfig.smtp.pass) + } + + // Speichern in Supabase + const { data, error } = await server.supabase + .from("helpdesk_channel_instances") + .insert({ + tenant_id, + type_id, + name, + config: safeConfig, + is_active, + }) + .select() + .single() + + if (error) throw error + + // sensible Felder aus Response entfernen + if (data.config?.imap) { + delete data.config.imap.host + delete data.config.imap.user + delete data.config.imap.pass + } + if (data.config?.smtp) { + delete data.config.smtp.host + delete data.config.smtp.user + delete data.config.smtp.pass + } + + reply.send({ + message: "E-Mail-Channel erfolgreich erstellt", + channel: data, + }) + } catch (err) { + console.error("Fehler bei Channel-Erstellung:", err) + reply.status(500).send({ error: err.message }) + } + }, + }) + + server.post("/helpdesk/conversations/:id/reply", { + schema: { + body: { + type: "object", + required: ["text"], + properties: { + text: { type: "string" }, + }, + }, + }, + handler: async (req, reply) => { + const conversationId = (req.params as any).id + const { text } = req.body as { text: string } + + // 🔹 Konversation inkl. Channel + Kontakt laden + const { data: conv, error: convErr } = await server.supabase + .from("helpdesk_conversations") + .select(` + id, + tenant_id, + subject, + channel_instance_id, + helpdesk_contacts(email), + helpdesk_channel_instances(config, name) + `) + .eq("id", conversationId) + .single() + + console.log(conv) + + if (convErr || !conv) { + reply.status(404).send({ error: "Konversation nicht gefunden" }) + return + } + + const contact = conv.helpdesk_contacts + const channel = conv.helpdesk_channel_instances + + console.log(contact) + if (!contact?.email) { + reply.status(400).send({ error: "Kein Empfänger gefunden" }) + return + } + + // 🔐 SMTP-Daten entschlüsseln + try { + const smtp = channel?.config?.smtp + const host = + typeof smtp.host === "object" ? decrypt(smtp.host) : smtp.host + const user = + typeof smtp.user === "object" ? decrypt(smtp.user) : smtp.user + const pass = + typeof smtp.pass === "object" ? decrypt(smtp.pass) : smtp.pass + + // 🔧 Transporter + const transporter = nodemailer.createTransport({ + host, + port: smtp.port || 465, + secure: smtp.secure ?? true, + auth: { user, pass }, + }) + + // 📩 Mail senden + const mailOptions = { + from: `"${channel?.name}" <${user}>`, + to: contact.email, + subject: conv.subject || "Antwort vom FEDEO Helpdesk", + text, + } + + const info = await transporter.sendMail(mailOptions) + console.log(`[Helpdesk SMTP] Gesendet an ${contact.email}: ${info.messageId}`) + + // 💾 Nachricht speichern + const { error: insertErr } = await server.supabase + .from("helpdesk_messages") + .insert({ + tenant_id: conv.tenant_id, + conversation_id: conversationId, + direction: "outgoing", + payload: { type: "text", text }, + external_message_id: info.messageId, + received_at: new Date().toISOString(), + }) + + if (insertErr) throw insertErr + + // 🔁 Konversation aktualisieren + await server.supabase + .from("helpdesk_conversations") + .update({ last_message_at: new Date().toISOString() }) + .eq("id", conversationId) + + reply.send({ + message: "E-Mail erfolgreich gesendet", + messageId: info.messageId, + }) + } catch (err: any) { + console.error("Fehler beim SMTP-Versand:", err) + reply.status(500).send({ error: err.message }) + } + }, + }) + +} + +export default helpdeskRoutes