Files
FEDEO/backend/src/modules/notification.service.ts
2026-01-06 12:07:43 +01:00

149 lines
5.0 KiB
TypeScript

// 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<string, unknown>;
}
export interface UserDirectoryInfo {
email?: string;
}
export type UserDirectory = (server: FastifyInstance, userId: string, tenantId: number) => Promise<UserDirectoryInfo | null>;
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 `
<html><body style="font-family:sans-serif;color:#222">
<div style="border:1px solid #ddd;border-radius:8px;padding:16px;max-width:600px;margin:auto">
<h2 style="color:#0f62fe;margin:0 0 12px">FEDEO</h2>
<h3 style="margin:0 0 8px">${this.escapeHtml(title)}</h3>
<p>${this.nl2br(this.escapeHtml(message))}</p>
<hr style="margin:16px 0;border:none;border-top:1px solid #eee"/>
<p style="font-size:12px;color:#666">Automatisch generiert von FEDEO</p>
</div>
</body></html>
`;
}
// simple escaping (ausreichend für unser Template)
private escapeHtml(s: string) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
private nl2br(s: string) {
return s.replace(/\n/g, '<br/>');
}
}