Added Helpdesk

Added M2M Auth
This commit is contained in:
2025-10-31 16:27:56 +01:00
parent bbd5bbab9b
commit 2eb19b36a6
7 changed files with 232 additions and 7 deletions

View File

@@ -24,6 +24,9 @@ import emailAsUserRoutes from "./routes/emailAsUser";
import authProfilesRoutes from "./routes/profiles"; import authProfilesRoutes from "./routes/profiles";
import helpdeskRoutes from "./routes/helpdesk"; import helpdeskRoutes from "./routes/helpdesk";
import helpdeskInboundRoutes from "./routes/helpdesk.inbound"; 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 {sendMail} from "./utils/mailer";
import {loadSecrets, secrets} from "./utils/secrets"; import {loadSecrets, secrets} from "./utils/secrets";
@@ -67,6 +70,11 @@ async function main() {
await app.register(helpdeskInboundRoutes); await app.register(helpdeskInboundRoutes);
await app.register(async (m2mApp) => {
await m2mApp.register(authM2m)
await m2mApp.register(helpdeskInboundEmailRoutes)
},{prefix: "/internal"})
//Geschützte Routes //Geschützte Routes

View File

@@ -1,6 +1,7 @@
// modules/helpdesk/helpdesk.conversation.service.ts // modules/helpdesk/helpdesk.conversation.service.ts
import { FastifyInstance } from 'fastify' import { FastifyInstance } from 'fastify'
import { getOrCreateContact } from './helpdesk.contact.service.js' import { getOrCreateContact } from './helpdesk.contact.service.js'
import {useNextNumberRangeNumber} from "../../utils/functions";
export async function createConversation( export async function createConversation(
server: FastifyInstance, server: FastifyInstance,
@@ -22,6 +23,8 @@ export async function createConversation(
) { ) {
const contactRecord = await getOrCreateContact(server, tenant_id, contact) const contactRecord = await getOrCreateContact(server, tenant_id, contact)
const {usedNumber } = await useNextNumberRangeNumber(server, tenant_id, "tickets")
const { data, error } = await server.supabase const { data, error } = await server.supabase
.from('helpdesk_conversations') .from('helpdesk_conversations')
.insert({ .insert({
@@ -32,7 +35,8 @@ export async function createConversation(
status: 'open', status: 'open',
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
customer_id, customer_id,
contact_person_id contact_person_id,
ticket_number: usedNumber
}) })
.select() .select()
.single() .single()
@@ -55,10 +59,15 @@ export async function getConversations(
const { data, error } = await query const { data, error } = await query
if (error) throw error if (error) throw error
return {
...data, const mappedData = data.map(entry => {
customer: data.customer_id return {
} ...entry,
customer: entry.customer_id
}
})
return mappedData
} }
export async function updateConversationStatus( export async function updateConversationStatus(

50
src/plugins/auth.m2m.ts Normal file
View File

@@ -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;
};
}
}

View File

@@ -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

View File

@@ -220,7 +220,8 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
subject, subject,
channel_instance_id, channel_instance_id,
helpdesk_contacts(email), helpdesk_contacts(email),
helpdesk_channel_instances(config, name) helpdesk_channel_instances(config, name),
ticket_number
`) `)
.eq("id", conversationId) .eq("id", conversationId)
.single() .single()
@@ -263,7 +264,7 @@ const helpdeskRoutes: FastifyPluginAsync = async (server) => {
const mailOptions = { const mailOptions = {
from: `"${channel?.name}" <${user}>`, from: `"${channel?.name}" <${user}>`,
to: contact.email, to: contact.email,
subject: conv.subject || "Antwort vom FEDEO Helpdesk", subject: `${conv.ticket_number} | ${conv.subject}` || `${conv.ticket_number} | Antwort vom FEDEO Helpdesk`,
text, text,
} }

56
src/utils/helpers.ts Normal file
View File

@@ -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
}

View File

@@ -27,6 +27,8 @@ export let secrets = {
S3_REGION: string S3_REGION: string
S3_ACCESS_KEY: string S3_ACCESS_KEY: string
S3_SECRET_KEY: string S3_SECRET_KEY: string
M2M_API_KEY: string
API_BASE_URL: string
} }
export async function loadSecrets () { export async function loadSecrets () {