// 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, '
'); } }