151 lines
5.1 KiB
TypeScript
151 lines
5.1 KiB
TypeScript
// 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<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;
|
|
|
|
// 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 `
|
|
<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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
|
|
private nl2br(s: string) {
|
|
return s.replace(/\n/g, '<br/>');
|
|
}
|
|
}
|