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 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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
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,
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
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_ACCESS_KEY: string
|
||||
S3_SECRET_KEY: string
|
||||
M2M_API_KEY: string
|
||||
API_BASE_URL: string
|
||||
}
|
||||
|
||||
export async function loadSecrets () {
|
||||
|
||||
Reference in New Issue
Block a user