Added Notification Basics
This commit is contained in:
@@ -24,6 +24,8 @@ import emailAsUserRoutes from "./routes/emailAsUser";
|
|||||||
import authProfilesRoutes from "./routes/profiles";
|
import authProfilesRoutes from "./routes/profiles";
|
||||||
import helpdeskRoutes from "./routes/helpdesk";
|
import helpdeskRoutes from "./routes/helpdesk";
|
||||||
import helpdeskInboundRoutes from "./routes/helpdesk.inbound";
|
import helpdeskInboundRoutes from "./routes/helpdesk.inbound";
|
||||||
|
import notificationsRoutes from "./routes/notifications";
|
||||||
|
|
||||||
//M2M
|
//M2M
|
||||||
import authM2m from "./plugins/auth.m2m";
|
import authM2m from "./plugins/auth.m2m";
|
||||||
import helpdeskInboundEmailRoutes from "./routes/helpdesk.inbound.email";
|
import helpdeskInboundEmailRoutes from "./routes/helpdesk.inbound.email";
|
||||||
|
|||||||
148
src/modules/notification.service.ts
Normal file
148
src/modules/notification.service.ts
Normal file
@@ -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<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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
private nl2br(s: string) {
|
||||||
|
return s.replace(/\n/g, '<br/>');
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/routes/notifications.ts
Normal file
30
src/routes/notifications.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user