From 2eb19b36a6429365cf0be8bf6e4a82608ac4f3e6 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Fri, 31 Oct 2025 16:27:56 +0100 Subject: [PATCH] Added Helpdesk Added M2M Auth --- src/index.ts | 8 ++ .../helpdesk/helpdesk.conversation.service.ts | 19 +++- src/plugins/auth.m2m.ts | 50 ++++++++++ src/routes/helpdesk.inbound.email.ts | 99 +++++++++++++++++++ src/routes/helpdesk.ts | 5 +- src/utils/helpers.ts | 56 +++++++++++ src/utils/secrets.ts | 2 + 7 files changed, 232 insertions(+), 7 deletions(-) create mode 100644 src/plugins/auth.m2m.ts create mode 100644 src/routes/helpdesk.inbound.email.ts create mode 100644 src/utils/helpers.ts diff --git a/src/index.ts b/src/index.ts index 11e739b..b567e46 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,9 @@ import emailAsUserRoutes from "./routes/emailAsUser"; import authProfilesRoutes from "./routes/profiles"; import helpdeskRoutes from "./routes/helpdesk"; import helpdeskInboundRoutes from "./routes/helpdesk.inbound"; +//M2M +import authM2m from "./plugins/auth.m2m"; +import helpdeskInboundEmailRoutes from "./routes/helpdesk.inbound.email"; import {sendMail} from "./utils/mailer"; import {loadSecrets, secrets} from "./utils/secrets"; @@ -67,6 +70,11 @@ async function main() { await app.register(helpdeskInboundRoutes); + await app.register(async (m2mApp) => { + await m2mApp.register(authM2m) + await m2mApp.register(helpdeskInboundEmailRoutes) + },{prefix: "/internal"}) + //Geschützte Routes diff --git a/src/modules/helpdesk/helpdesk.conversation.service.ts b/src/modules/helpdesk/helpdesk.conversation.service.ts index 9c5ff9f..15efe7b 100644 --- a/src/modules/helpdesk/helpdesk.conversation.service.ts +++ b/src/modules/helpdesk/helpdesk.conversation.service.ts @@ -1,6 +1,7 @@ // modules/helpdesk/helpdesk.conversation.service.ts import { FastifyInstance } from 'fastify' import { getOrCreateContact } from './helpdesk.contact.service.js' +import {useNextNumberRangeNumber} from "../../utils/functions"; export async function createConversation( server: FastifyInstance, @@ -22,6 +23,8 @@ export async function createConversation( ) { const contactRecord = await getOrCreateContact(server, tenant_id, contact) + const {usedNumber } = await useNextNumberRangeNumber(server, tenant_id, "tickets") + const { data, error } = await server.supabase .from('helpdesk_conversations') .insert({ @@ -32,7 +35,8 @@ export async function createConversation( status: 'open', created_at: new Date().toISOString(), customer_id, - contact_person_id + contact_person_id, + ticket_number: usedNumber }) .select() .single() @@ -55,10 +59,15 @@ export async function getConversations( const { data, error } = await query if (error) throw error - return { - ...data, - customer: data.customer_id - } + + const mappedData = data.map(entry => { + return { + ...entry, + customer: entry.customer_id + } + }) + + return mappedData } export async function updateConversationStatus( diff --git a/src/plugins/auth.m2m.ts b/src/plugins/auth.m2m.ts new file mode 100644 index 0000000..2523b23 --- /dev/null +++ b/src/plugins/auth.m2m.ts @@ -0,0 +1,50 @@ +import { FastifyInstance } from "fastify"; +import fp from "fastify-plugin"; +import { secrets } from "../utils/secrets"; + +/** + * Fastify Plugin für Machine-to-Machine Authentifizierung. + * + * Dieses Plugin prüft, ob der Header `x-api-key` vorhanden ist + * und mit dem in der .env hinterlegten M2M_API_KEY übereinstimmt. + * + * Verwendung: + * server.register(m2mAuthPlugin, { allowedPrefix: '/internal' }) + */ +export default fp(async (server: FastifyInstance, opts: { allowedPrefix?: string } = {}) => { + //const allowedPrefix = opts.allowedPrefix || "/internal"; + + server.addHook("preHandler", async (req, reply) => { + try { + // Nur prüfen, wenn Route unterhalb des Prefix liegt + //if (!req.url.startsWith(allowedPrefix)) return; + + const apiKey = req.headers["x-api-key"]; + + if (!apiKey || apiKey !== secrets.M2M_API_KEY) { + server.log.warn(`[M2M Auth] Ungültiger oder fehlender API-Key bei ${req.url}`); + return reply.status(401).send({ error: "Unauthorized" }); + } + + // Zusatzinformationen im Request (z. B. interne Kennung) + (req as any).m2m = { + verified: true, + type: "internal", + key: apiKey, + }; + } catch (err) { + server.log.error("[M2M Auth] Fehler beim Prüfen des API-Keys:", err); + return reply.status(500).send({ error: "Internal Server Error" }); + } + }); +}); + +declare module "fastify" { + interface FastifyRequest { + m2m?: { + verified: boolean; + type: "internal"; + key: string; + }; + } +} diff --git a/src/routes/helpdesk.inbound.email.ts b/src/routes/helpdesk.inbound.email.ts new file mode 100644 index 0000000..7c0b9fb --- /dev/null +++ b/src/routes/helpdesk.inbound.email.ts @@ -0,0 +1,99 @@ +// modules/helpdesk/helpdesk.inbound.email.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' +import {extractDomain, findCustomerOrContactByEmailOrDomain} from "../utils/helpers"; +import {useNextNumberRangeNumber} from "../utils/functions"; + +// ------------------------------------------------------------- +// 📧 Interne M2M-Route für eingehende E-Mails +// ------------------------------------------------------------- + +const helpdeskInboundEmailRoutes: FastifyPluginAsync = async (server) => { + server.post('/helpdesk/inbound-email', async (req, res) => { + + const { + tenant_id, + channel_id, + from, + subject, + text, + message_id, + in_reply_to, + } = req.body + + if (!tenant_id || !from?.address || !text) { + return res.status(400).send({ error: 'Invalid payload' }) + } + + server.log.info(`[InboundEmail] Neue Mail von ${from.address} für Tenant ${tenant_id}`) + + // 1️⃣ Kunde & Kontakt ermitteln + const { customer, contact: contactPerson } = + (await findCustomerOrContactByEmailOrDomain(server, from.address, tenant_id)) || {} + + // 2️⃣ Kontakt anlegen oder laden + const contact = await getOrCreateContact(server, tenant_id, { + email: from.address, + display_name: from.name || from.address, + customer_id: customer, + contact_id: contactPerson, + }) + + // 3️⃣ Konversation anhand In-Reply-To suchen + let conversationId: number | null = null + if (in_reply_to) { + const { data: msg } = await server.supabase + .from('helpdesk_messages') + .select('conversation_id') + .eq('external_message_id', in_reply_to) + .maybeSingle() + conversationId = msg?.conversation_id || null + } + + // 4️⃣ Neue Konversation anlegen falls keine existiert + let conversation + if (!conversationId) { + const { usedNumber } = await useNextNumberRangeNumber(server, tenant_id, 'tickets') + conversation = await createConversation(server, { + tenant_id, + contact, + channel_instance_id: channel_id, + status: 'open', + subject: subject || '(kein Betreff)', + customer_id: customer, + contact_person_id: contactPerson, + ticket_number: usedNumber, + }) + conversationId = conversation.id + } else { + const { data } = await server.supabase + .from('helpdesk_conversations') + .select('*') + .eq('id', conversationId) + .single() + conversation = data + } + + // 5️⃣ Nachricht speichern + await addMessage(server, { + tenant_id, + conversation_id: conversationId, + direction: 'incoming', + payload: { type: 'text', text }, + external_message_id: message_id, + raw_meta: { source: 'email' }, + }) + + server.log.info(`[InboundEmail] Ticket ${conversationId} gespeichert`) + + return res.status(201).send({ + success: true, + conversation_id: conversationId, + ticket_number: conversation.ticket_number, + }) + }) +} + +export default helpdeskInboundEmailRoutes diff --git a/src/routes/helpdesk.ts b/src/routes/helpdesk.ts index 0e4d302..a194d25 100644 --- a/src/routes/helpdesk.ts +++ b/src/routes/helpdesk.ts @@ -220,7 +220,8 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => { subject, channel_instance_id, helpdesk_contacts(email), - helpdesk_channel_instances(config, name) + helpdesk_channel_instances(config, name), + ticket_number `) .eq("id", conversationId) .single() @@ -263,7 +264,7 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => { const mailOptions = { from: `"${channel?.name}" <${user}>`, to: contact.email, - subject: conv.subject || "Antwort vom FEDEO Helpdesk", + subject: `${conv.ticket_number} | ${conv.subject}` || `${conv.ticket_number} | Antwort vom FEDEO Helpdesk`, text, } diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts new file mode 100644 index 0000000..fdd7046 --- /dev/null +++ b/src/utils/helpers.ts @@ -0,0 +1,56 @@ +// 🔧 Hilfsfunktionen +import {FastifyInstance} from "fastify"; + +export function extractDomain(email: string) { + if (!email) return null + const parts = email.split('@') + return parts.length === 2 ? parts[1].toLowerCase() : null +} + +export async function findCustomerOrContactByEmailOrDomain(server:FastifyInstance, fromMail: string, tenantId: number) { + const sender = fromMail.toLowerCase() + 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) { + return { customer: contactMatch.customer, contact: contactMatch.id } + } + + // 2️⃣ Kunden nach Domain oder Rechnungs-E-Mail durchsuchen + const { data: customers, error } = await server.supabase + .from('customers') + .select('id, infoData') + .eq('tenant', tenantId) + + if (error) { + server.log.error(`[Helpdesk] Fehler beim Laden der Kunden: ${error.message}`) + return null + } + + 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) + + if ( + sender === email || + sender === invoiceEmail || + senderDomain === emailDomain || + senderDomain === invoiceDomain + ) { + return { customer: c.id, contact: null } + } + } + + return null +} \ No newline at end of file diff --git a/src/utils/secrets.ts b/src/utils/secrets.ts index 0d20dda..0107b28 100644 --- a/src/utils/secrets.ts +++ b/src/utils/secrets.ts @@ -27,6 +27,8 @@ export let secrets = { S3_REGION: string S3_ACCESS_KEY: string S3_SECRET_KEY: string + M2M_API_KEY: string + API_BASE_URL: string } export async function loadSecrets () {