// services/notification.service.ts import type { FastifyInstance } from 'fastify'; import {secrets} from "../utils/secrets"; import { eq } from "drizzle-orm"; import { notificationsEventTypes, notificationsItems } from "../../db/schema"; 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; // 1) Event-Typ prüfen (aktiv?) const eventTypeRows = await this.server.db .select() .from(notificationsEventTypes) .where(eq(notificationsEventTypes.eventKey, eventType)) .limit(1) const eventTypeRow = eventTypeRows[0] if (!eventTypeRow || eventTypeRow.isActive !== 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 insertedRows = await this.server.db .insert(notificationsItems) .values({ tenantId, userId, eventType, title, message, payload: payload ?? null, channel: 'email', status: 'queued' }) .returning({ id: notificationsItems.id }) const inserted = insertedRows[0] if (!inserted) { throw new Error("Fehler beim Einfügen der Notification"); } // 4) E-Mail versenden try { await this.sendEmail(user.email, title, message); await this.server.db .update(notificationsItems) .set({ status: 'sent', sentAt: new Date() }) .where(eq(notificationsItems.id, inserted.id)); return { success: true, id: inserted.id }; } catch (err: any) { await this.server.db .update(notificationsItems) .set({ status: 'failed', error: String(err?.message || err) }) .where(eq(notificationsItems.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, '
'); } }