Added Helpdesk
Added M2M Auth
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
const mappedData = data.map(entry => {
|
||||||
return {
|
return {
|
||||||
...data,
|
...entry,
|
||||||
customer: data.customer_id
|
customer: entry.customer_id
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return mappedData
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateConversationStatus(
|
export async function updateConversationStatus(
|
||||||
|
|||||||
50
src/plugins/auth.m2m.ts
Normal file
50
src/plugins/auth.m2m.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/routes/helpdesk.inbound.email.ts
Normal file
99
src/routes/helpdesk.inbound.email.ts
Normal 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
|
||||||
@@ -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
56
src/utils/helpers.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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 () {
|
||||||
|
|||||||
Reference in New Issue
Block a user