From 3bd4ac1f56b8c8411b3c7f4a5769a42348b6635f Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Fri, 31 Oct 2025 16:28:17 +0100 Subject: [PATCH] Added Notification Basics --- src/index.ts | 2 + src/modules/notification.service.ts | 148 ++++++++++++++++++++++++++++ src/routes/notifications.ts | 30 ++++++ 3 files changed, 180 insertions(+) create mode 100644 src/modules/notification.service.ts create mode 100644 src/routes/notifications.ts diff --git a/src/index.ts b/src/index.ts index b567e46..33e43d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,8 @@ import emailAsUserRoutes from "./routes/emailAsUser"; import authProfilesRoutes from "./routes/profiles"; import helpdeskRoutes from "./routes/helpdesk"; import helpdeskInboundRoutes from "./routes/helpdesk.inbound"; +import notificationsRoutes from "./routes/notifications"; + //M2M import authM2m from "./plugins/auth.m2m"; import helpdeskInboundEmailRoutes from "./routes/helpdesk.inbound.email"; diff --git a/src/modules/notification.service.ts b/src/modules/notification.service.ts new file mode 100644 index 0000000..241fea4 --- /dev/null +++ b/src/modules/notification.service.ts @@ -0,0 +1,148 @@ +// services/notification.service.ts +import type { FastifyInstance } from 'fastify'; +import {secrets} from "../utils/secrets"; + +export type NotificationStatus = 'queued' | 'sent' | 'failed'; + +export interface TriggerInput { + tenantId: number; + userId: string; // muss auf public.auth_users.id zeigen + eventType: string; // muss in notifications_event_types existieren + title: string; // Betreff/Title + message: string; // Klartext-Inhalt + payload?: Record; +} + +export interface UserDirectoryInfo { + email?: string; +} + +export type UserDirectory = (server: FastifyInstance, userId: string, tenantId: number) => Promise; + +export class NotificationService { + constructor( + private server: FastifyInstance, + private getUser: UserDirectory + ) {} + + /** + * Löst eine E-Mail-Benachrichtigung aus: + * - Validiert den Event-Typ + * - Legt einen Datensatz in notifications_items an (status: queued) + * - Versendet E-Mail (FEDEO Branding) + * - Aktualisiert status/sent_at bzw. error + */ + async trigger(input: TriggerInput) { + const { tenantId, userId, eventType, title, message, payload } = input; + const supabase = this.server.supabase; + + // 1) Event-Typ prüfen (aktiv?) + const { data: eventTypeRow, error: etErr } = await supabase + .from('notifications_event_types') + .select('event_key,is_active') + .eq('event_key', eventType) + .maybeSingle(); + + if (etErr || !eventTypeRow || eventTypeRow.is_active !== true) { + throw new Error(`Unbekannter oder inaktiver Event-Typ: ${eventType}`); + } + + // 2) Zieladresse beschaffen + const user = await this.getUser(this.server, userId, tenantId); + if (!user?.email) { + throw new Error(`Nutzer ${userId} hat keine E-Mail-Adresse`); + } + + // 3) Notification anlegen (status: queued) + const { data: inserted, error: insErr } = await supabase + .from('notifications_items') + .insert({ + tenant_id: tenantId, + user_id: userId, + event_type: eventType, + title, + message, + payload: payload ?? null, + channel: 'email', + status: 'queued' + }) + .select('id') + .single(); + + if (insErr || !inserted) { + throw new Error(`Fehler beim Einfügen der Notification: ${insErr?.message}`); + } + + // 4) E-Mail versenden + try { + await this.sendEmail(user.email, title, message); + + await supabase + .from('notifications_items') + .update({ status: 'sent', sent_at: new Date().toISOString() }) + .eq('id', inserted.id); + + return { success: true, id: inserted.id }; + } catch (err: any) { + await supabase + .from('notifications_items') + .update({ status: 'failed', error: String(err?.message || err) }) + .eq('id', inserted.id); + + this.server.log.error({ err, notificationId: inserted.id }, 'E-Mail Versand fehlgeschlagen'); + return { success: false, error: err?.message || 'E-Mail Versand fehlgeschlagen' }; + } + } + + // ---- private helpers ------------------------------------------------------ + + private async sendEmail(to: string, subject: string, message: string) { + const nodemailer = await import('nodemailer'); + + const transporter = nodemailer.createTransport({ + host: secrets.MAILER_SMTP_HOST, + port: Number(secrets.MAILER_SMTP_PORT), + secure: secrets.MAILER_SMTP_SSL === 'true', + auth: { + user: secrets.MAILER_SMTP_USER, + pass: secrets.MAILER_SMTP_PASS + } + }); + + const html = this.renderFedeoHtml(subject, message); + + await transporter.sendMail({ + from: secrets.MAILER_FROM, + to, + subject, + text: message, + html + }); + } + + private renderFedeoHtml(title: string, message: string) { + return ` + +
+

FEDEO

+

${this.escapeHtml(title)}

+

${this.nl2br(this.escapeHtml(message))}

+
+

Automatisch generiert von FEDEO

+
+ + `; + } + + // simple escaping (ausreichend für unser Template) + private escapeHtml(s: string) { + return s + .replace(/&/g, '&') + .replace(//g, '>'); + } + + private nl2br(s: string) { + return s.replace(/\n/g, '
'); + } +} diff --git a/src/routes/notifications.ts b/src/routes/notifications.ts new file mode 100644 index 0000000..395a6c5 --- /dev/null +++ b/src/routes/notifications.ts @@ -0,0 +1,30 @@ +// routes/notifications.routes.ts +import { FastifyInstance } from 'fastify'; +import { NotificationService, UserDirectory } from '../modules/notification.service'; + +// Beispiel: E-Mail aus eigener User-Tabelle laden +const getUserDirectory: UserDirectory = async (server:FastifyInstance, userId, tenantId) => { + const { data, error } = await server.supabase + .from('auth_users') + .select('email') + .eq('id', userId) + .maybeSingle(); + if (error || !data) return null; + return { email: data.email }; +}; + +export default async function notificationsRoutes(server: FastifyInstance) { + // wichtig: server.supabase ist über app verfügbar + + const svc = new NotificationService(server, getUserDirectory); + + server.post('/notifications/trigger', async (req, reply) => { + try { + const res = await svc.trigger(req.body as any); + reply.send(res); + } catch (err: any) { + server.log.error(err); + reply.code(500).send({ error: err.message }); + } + }); +}