Added Notification Basics
This commit is contained in:
@@ -24,6 +24,8 @@ import emailAsUserRoutes from "./routes/emailAsUser";
|
||||
import authProfilesRoutes from "./routes/profiles";
|
||||
import helpdeskRoutes from "./routes/helpdesk";
|
||||
import helpdeskInboundRoutes from "./routes/helpdesk.inbound";
|
||||
import notificationsRoutes from "./routes/notifications";
|
||||
|
||||
//M2M
|
||||
import authM2m from "./plugins/auth.m2m";
|
||||
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