KI-AGENT: Zentrale Benachrichtigungsengine mit Desktop Push umsetzen

This commit is contained in:
2026-05-18 19:51:08 +02:00
parent 24c09d7891
commit 4aeefb2b83
15 changed files with 11252 additions and 109 deletions

View File

@@ -25,6 +25,12 @@ MAILER_SMTP_USER=mailer@example.com
MAILER_SMTP_PASS=change-this-mail-password
MAILER_FROM=FEDEO <no-reply@example.com>
# Desktop Push per Web Push. Schlüssel können mit
# `npx web-push generate-vapid-keys` erzeugt werden.
WEB_PUSH_PUBLIC_KEY=replace-this-web-push-public-key
WEB_PUSH_PRIVATE_KEY=replace-this-web-push-private-key
WEB_PUSH_SUBJECT=mailto:admin@example.com
S3_ENDPOINT=http://minio:9000
S3_REGION=eu-central-1
S3_ACCESS_KEY=fedeo-minio

View File

@@ -0,0 +1,46 @@
CREATE TABLE "notification_push_subscriptions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"user_id" uuid NOT NULL,
"endpoint" text NOT NULL,
"p256dh" text NOT NULL,
"auth" text NOT NULL,
"user_agent" text,
"device_label" text,
"meta" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
"disabled_at" timestamp with time zone,
CONSTRAINT "notification_push_subscriptions_endpoint_key" UNIQUE("endpoint")
);
ALTER TABLE "notification_push_subscriptions"
ADD CONSTRAINT "notification_push_subscriptions_tenant_id_tenants_id_fk"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id")
ON DELETE cascade ON UPDATE cascade;
ALTER TABLE "notification_push_subscriptions"
ADD CONSTRAINT "notification_push_subscriptions_user_id_auth_users_id_fk"
FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id")
ON DELETE cascade ON UPDATE cascade;
INSERT INTO "notifications_event_types" (
"event_key",
"display_name",
"description",
"category",
"severity",
"allowed_channels"
) VALUES
('system.test_push', 'Test-Push', 'Testet Desktop-Benachrichtigungen für den angemeldeten Nutzer.', 'system', 'info', '["inapp", "push"]'::jsonb),
('communication.message.new', 'Neue Chatnachricht', 'Benachrichtigt über relevante neue Chatnachrichten.', 'communication', 'info', '["inapp", "push"]'::jsonb),
('communication.call.started', 'Besprechung gestartet', 'Benachrichtigt Raumteilnehmer über gestartete Audio- oder Videoanrufe.', 'communication', 'info', '["inapp", "push"]'::jsonb),
('communication.call.missed', 'Verpasster Anruf', 'Benachrichtigt über verpasste Besprechungen.', 'communication', 'warning', '["inapp", "push"]'::jsonb),
('communication.room.invited', 'Raumeinladung', 'Benachrichtigt über Einladungen in Kommunikationsräume.', 'communication', 'info', '["inapp", "push"]'::jsonb)
ON CONFLICT ("event_key") DO UPDATE SET
"display_name" = EXCLUDED."display_name",
"description" = EXCLUDED."description",
"category" = EXCLUDED."category",
"severity" = EXCLUDED."severity",
"allowed_channels" = EXCLUDED."allowed_channels",
"is_active" = true;

View File

@@ -57,6 +57,7 @@ export * from "./notifications_event_types"
export * from "./notifications_items"
export * from "./notifications_preferences"
export * from "./notifications_preferences_defaults"
export * from "./notification_push_subscriptions"
export * from "./ownaccounts"
export * from "./outgoingsepamandates"
export * from "./plants"

View File

@@ -0,0 +1,50 @@
import {
pgTable,
uuid,
bigint,
text,
jsonb,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const notificationPushSubscriptions = pgTable(
"notification_push_subscriptions",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id, { onDelete: "cascade", onUpdate: "cascade" }),
userId: uuid("user_id")
.notNull()
.references(() => authUsers.id, { onDelete: "cascade", onUpdate: "cascade" }),
endpoint: text("endpoint").notNull(),
p256dh: text("p256dh").notNull(),
auth: text("auth").notNull(),
userAgent: text("user_agent"),
deviceLabel: text("device_label"),
meta: jsonb("meta"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
lastSeenAt: timestamp("last_seen_at", { withTimezone: true })
.notNull()
.defaultNow(),
disabledAt: timestamp("disabled_at", { withTimezone: true }),
},
(table) => ({
uniqueEndpoint: uniqueIndex("notification_push_subscriptions_endpoint_key").on(table.endpoint),
}),
)
export type NotificationPushSubscription =
typeof notificationPushSubscriptions.$inferSelect
export type NewNotificationPushSubscription =
typeof notificationPushSubscriptions.$inferInsert

10423
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -54,6 +54,7 @@
"pg": "^8.16.3",
"pngjs": "^7.0.0",
"sharp": "^0.34.5",
"web-push": "^3.6.7",
"webdav-server": "^2.6.2",
"xmlbuilder": "^15.1.1",
"zpl-image": "^0.2.0",
@@ -63,6 +64,7 @@
"@types/bcrypt": "^6.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.3.0",
"@types/web-push": "^3.6.4",
"drizzle-kit": "^0.31.8",
"prisma": "^6.15.0",
"tsx": "^4.20.5",

View File

@@ -1,25 +1,51 @@
// 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";
import type { FastifyInstance } from "fastify"
import webPush from "web-push"
import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm"
import {
authUsers,
notificationPushSubscriptions,
notificationsEventTypes,
notificationsItems,
notificationsPreferences,
notificationsPreferencesDefaults,
} from "../../db/schema"
import { secrets } from "../utils/secrets"
export type NotificationStatus = 'queued' | 'sent' | 'failed';
export type NotificationChannel = "inapp" | "email" | "push" | "webhook" | "sms"
export type NotificationStatus = "queued" | "sent" | "failed" | "read"
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>;
tenantId: number
userId?: string
userIds?: string[]
eventType: string
title: string
message: string
payload?: Record<string, unknown>
channels?: NotificationChannel[]
}
export interface PushSubscriptionInput {
endpoint: string
keys: {
p256dh: string
auth: string
}
deviceLabel?: string
meta?: Record<string, unknown>
}
export interface UserDirectoryInfo {
email?: string;
email?: string
}
export type UserDirectory = (server: FastifyInstance, userId: string, tenantId: number) => Promise<UserDirectoryInfo | null>;
export type UserDirectory = (
server: FastifyInstance,
userId: string,
tenantId: number
) => Promise<UserDirectoryInfo | null>
const DEFAULT_CHANNELS: NotificationChannel[] = ["inapp"]
export class NotificationService {
constructor(
@@ -27,99 +53,355 @@ export class NotificationService {
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 tenantId = input.tenantId
const userIds = Array.from(new Set([...(input.userIds || []), input.userId].filter(Boolean))) as string[]
// 1) Event-Typ prüfen (aktiv?)
const eventTypeRows = await this.server.db
if (!tenantId) throw new Error("tenantId fehlt")
if (!userIds.length) throw new Error("Keine Empfänger angegeben")
const eventType = await this.getActiveEventType(input.eventType)
const allowedChannels = this.normalizeChannels(eventType.allowedChannels)
const requestedChannels = input.channels?.length ? input.channels : allowedChannels
const channels = requestedChannels.filter((channel) => allowedChannels.includes(channel))
if (!channels.length) {
return { success: true, created: 0, delivered: 0, skipped: userIds.length }
}
const results = []
for (const userId of userIds) {
const enabledChannels = await this.resolveEnabledChannels({
tenantId,
userId,
eventType: input.eventType,
channels,
})
for (const channel of enabledChannels) {
const itemRows = await this.server.db
.insert(notificationsItems)
.values({
tenantId,
userId,
eventType: input.eventType,
title: input.title,
message: input.message,
payload: input.payload ?? null,
channel,
status: "queued",
})
.returning()
const item = itemRows[0]
if (!item) continue
results.push(await this.deliver(item))
}
}
return {
success: results.every((result) => result.success),
created: results.length,
delivered: results.filter((result) => result.success).length,
failed: results.filter((result) => !result.success).length,
}
}
async listForUser(tenantId: number, userId: string, limit = 50) {
return await this.server.db
.select()
.from(notificationsItems)
.where(and(
eq(notificationsItems.tenantId, tenantId),
eq(notificationsItems.userId, userId),
eq(notificationsItems.channel, "inapp")
))
.orderBy(desc(notificationsItems.createdAt))
.limit(Math.min(Math.max(limit, 1), 100))
}
async markRead(tenantId: number, userId: string, notificationId: string) {
const rows = await this.server.db
.update(notificationsItems)
.set({ readAt: new Date(), status: "read" })
.where(and(
eq(notificationsItems.id, notificationId),
eq(notificationsItems.tenantId, tenantId),
eq(notificationsItems.userId, userId)
))
.returning()
return rows[0] || null
}
async registerPushSubscription(
tenantId: number,
userId: string,
subscription: PushSubscriptionInput,
userAgent?: string
) {
if (!subscription.endpoint || !subscription.keys?.p256dh || !subscription.keys?.auth) {
throw new Error("Push-Subscription ist unvollständig")
}
const rows = await this.server.db
.insert(notificationPushSubscriptions)
.values({
tenantId,
userId,
endpoint: subscription.endpoint,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
userAgent,
deviceLabel: subscription.deviceLabel,
meta: subscription.meta ?? null,
lastSeenAt: new Date(),
disabledAt: null,
})
.onConflictDoUpdate({
target: notificationPushSubscriptions.endpoint,
set: {
tenantId,
userId,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
userAgent,
deviceLabel: subscription.deviceLabel,
meta: subscription.meta ?? null,
lastSeenAt: new Date(),
disabledAt: null,
},
})
.returning()
return rows[0]
}
async disablePushSubscription(tenantId: number, userId: string, endpoint: string) {
await this.server.db
.update(notificationPushSubscriptions)
.set({ disabledAt: new Date() })
.where(and(
eq(notificationPushSubscriptions.tenantId, tenantId),
eq(notificationPushSubscriptions.userId, userId),
eq(notificationPushSubscriptions.endpoint, endpoint)
))
return { success: true }
}
getPublicPushConfig() {
return {
configured: Boolean(secrets.WEB_PUSH_PUBLIC_KEY && secrets.WEB_PUSH_PRIVATE_KEY),
publicKey: secrets.WEB_PUSH_PUBLIC_KEY || "",
}
}
private async getActiveEventType(eventType: string) {
const rows = 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}`);
const row = rows[0]
if (!row || row.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`);
return row
}
private normalizeChannels(value: unknown): NotificationChannel[] {
if (!Array.isArray(value)) return DEFAULT_CHANNELS
const valid = new Set(["inapp", "email", "push", "webhook", "sms"])
const channels = value.filter((channel): channel is NotificationChannel =>
typeof channel === "string" && valid.has(channel)
)
return channels.length ? channels : DEFAULT_CHANNELS
}
private async resolveEnabledChannels(input: {
tenantId: number
userId: string
eventType: string
channels: NotificationChannel[]
}) {
const prefs = await this.server.db
.select()
.from(notificationsPreferences)
.where(and(
eq(notificationsPreferences.tenantId, input.tenantId),
eq(notificationsPreferences.userId, input.userId),
eq(notificationsPreferences.eventType, input.eventType),
inArray(notificationsPreferences.channel, input.channels)
))
const defaults = await this.server.db
.select()
.from(notificationsPreferencesDefaults)
.where(and(
eq(notificationsPreferencesDefaults.tenantId, input.tenantId),
eq(notificationsPreferencesDefaults.eventKey, input.eventType),
inArray(notificationsPreferencesDefaults.channel, input.channels)
))
return input.channels.filter((channel) => {
const userPref = prefs.find((pref) => pref.channel === channel)
if (userPref) return userPref.enabled
const defaultPref = defaults.find((pref) => pref.channel === channel)
if (defaultPref) return defaultPref.enabled
return true
})
}
private async deliver(item: typeof notificationsItems.$inferSelect) {
if (item.channel === "inapp") {
await this.markSent(item.id)
return { success: true, id: item.id, channel: item.channel }
}
// 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");
if (item.channel === "push") {
return await this.deliverPush(item)
}
// 4) E-Mail versenden
if (item.channel === "email") {
return await this.deliverEmail(item)
}
await this.markFailed(item.id, `Kein Zusteller für Kanal ${item.channel}`)
return { success: false, id: item.id, channel: item.channel }
}
private async deliverPush(item: typeof notificationsItems.$inferSelect) {
if (!secrets.WEB_PUSH_PUBLIC_KEY || !secrets.WEB_PUSH_PRIVATE_KEY) {
await this.markFailed(item.id, "Web Push ist nicht konfiguriert")
return { success: false, id: item.id, channel: item.channel }
}
webPush.setVapidDetails(
secrets.WEB_PUSH_SUBJECT || "mailto:admin@example.com",
secrets.WEB_PUSH_PUBLIC_KEY,
secrets.WEB_PUSH_PRIVATE_KEY
)
const subscriptions = await this.server.db
.select()
.from(notificationPushSubscriptions)
.where(and(
eq(notificationPushSubscriptions.tenantId, item.tenantId),
eq(notificationPushSubscriptions.userId, item.userId),
isNull(notificationPushSubscriptions.disabledAt)
))
if (!subscriptions.length) {
await this.markFailed(item.id, "Keine aktive Push-Subscription")
return { success: false, id: item.id, channel: item.channel }
}
const payload = JSON.stringify({
id: item.id,
title: item.title,
message: item.message,
payload: item.payload || {},
})
let delivered = 0
const errors: string[] = []
for (const subscription of subscriptions) {
try {
await webPush.sendNotification({
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
}, payload)
delivered++
await this.server.db
.update(notificationPushSubscriptions)
.set({ lastSeenAt: new Date() })
.where(eq(notificationPushSubscriptions.id, subscription.id))
} catch (error: any) {
errors.push(error?.message || String(error))
if (error?.statusCode === 404 || error?.statusCode === 410) {
await this.server.db
.update(notificationPushSubscriptions)
.set({ disabledAt: new Date() })
.where(eq(notificationPushSubscriptions.id, subscription.id))
}
}
}
if (delivered > 0) {
await this.markSent(item.id)
return { success: true, id: item.id, channel: item.channel, delivered }
}
await this.markFailed(item.id, errors.join("; ") || "Push konnte nicht zugestellt werden")
return { success: false, id: item.id, channel: item.channel }
}
private async deliverEmail(item: typeof notificationsItems.$inferSelect) {
try {
await this.sendEmail(user.email, title, message);
const user = await this.getUser(this.server, item.userId, item.tenantId)
if (!user?.email) throw new Error(`Nutzer ${item.userId} hat keine E-Mail-Adresse`)
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' };
await this.sendEmail(user.email, item.title, item.message)
await this.markSent(item.id)
return { success: true, id: item.id, channel: item.channel }
} catch (error: any) {
await this.markFailed(item.id, error?.message || "E-Mail Versand fehlgeschlagen")
this.server.log.error({ err: error, notificationId: item.id }, "E-Mail Versand fehlgeschlagen")
return { success: false, id: item.id, channel: item.channel }
}
}
// ---- private helpers ------------------------------------------------------
private async markSent(id: string) {
await this.server.db
.update(notificationsItems)
.set({ status: "sent", sentAt: new Date() })
.where(eq(notificationsItems.id, id))
}
private async markFailed(id: string, error: string) {
await this.server.db
.update(notificationsItems)
.set({ status: "failed", error })
.where(eq(notificationsItems.id, id))
}
private async sendEmail(to: string, subject: string, message: string) {
const nodemailer = await import('nodemailer');
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',
secure: secrets.MAILER_SMTP_SSL === "true",
auth: {
user: secrets.MAILER_SMTP_USER,
pass: secrets.MAILER_SMTP_PASS
}
});
pass: secrets.MAILER_SMTP_PASS,
},
})
const html = this.renderFedeoHtml(subject, message);
const html = this.renderFedeoHtml(subject, message)
await transporter.sendMail({
from: secrets.MAILER_FROM,
to,
subject,
text: message,
html
});
html,
})
}
private renderFedeoHtml(title: string, message: string) {
@@ -133,18 +415,17 @@ export class NotificationService {
<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;');
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
}
private nl2br(s: string) {
return s.replace(/\n/g, '<br/>');
return s.replace(/\n/g, "<br/>")
}
}

View File

@@ -1,8 +1,22 @@
import { FastifyInstance } from "fastify"
import { and, eq, ne } from "drizzle-orm"
import { authTenantUsers, authUsers } from "../../db/schema"
import { matrixService } from "../modules/matrix.service"
import { NotificationService, UserDirectory } from "../modules/notification.service"
const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => {
const rows = await server.db
.select({ email: authUsers.email })
.from(authUsers)
.where(eq(authUsers.id, userId))
.limit(1)
return rows[0] || null
}
export default async function communicationRoutes(server: FastifyInstance) {
const matrix = matrixService(server)
const notifications = new NotificationService(server, getUserDirectory)
const handleMatrixError = (req: any, reply: any, err: any, fallbackMessage: string) => {
req.log.error(err)
return reply
@@ -33,6 +47,45 @@ export default async function communicationRoutes(server: FastifyInstance) {
}
}
const callModeFromRequest = (req: any): "audio" | "video" => {
const body = (req.body || {}) as { mode?: string }
return body.mode === "audio" ? "audio" : "video"
}
const notifyTenantUsersAboutCall = async (req: any, room: { key?: string; name?: string }, mode: "audio" | "video") => {
if (!req.user.tenant_id) return
try {
const recipientRows = await server.db
.select({ userId: authTenantUsers.user_id })
.from(authTenantUsers)
.where(and(
eq(authTenantUsers.tenant_id, req.user.tenant_id),
ne(authTenantUsers.user_id, req.user.user_id)
))
const userIds = recipientRows.map((row) => row.userId)
if (!userIds.length) return
await notifications.trigger({
tenantId: req.user.tenant_id,
userIds,
eventType: "communication.call.started",
title: mode === "audio" ? "Audioanruf gestartet" : "Videokonferenz gestartet",
message: `${room.name || room.key || "Ein Chatraum"} hat eine laufende Besprechung.`,
payload: {
link: "/communication/chat",
roomKey: room.key,
roomName: room.name,
mode,
},
channels: ["inapp", "push"],
})
} catch (err) {
req.log.error({ err }, "Call-Benachrichtigung konnte nicht ausgelöst werden")
}
}
server.get("/communication/matrix/status", async () => {
return matrix.getStatus()
})
@@ -138,10 +191,13 @@ export default async function communicationRoutes(server: FastifyInstance) {
server.post("/communication/matrix/rooms/general/call-session", async (req, reply) => {
try {
return await matrix.createLiveKitRoomSession(req.user.user_id, req.user.tenant_id, {
const room = {
key: "allgemein",
name: "Allgemeiner Chat",
})
}
const session = await matrix.createLiveKitRoomSession(req.user.user_id, req.user.tenant_id, room)
await notifyTenantUsersAboutCall(req, room, callModeFromRequest(req))
return session
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix call session failed")
}
@@ -226,11 +282,14 @@ export default async function communicationRoutes(server: FastifyInstance) {
server.post("/communication/matrix/rooms/:roomKey/call-session", async (req, reply) => {
try {
return await matrix.createLiveKitRoomSession(
const room = roomOptionsFromRequest(req)
const session = await matrix.createLiveKitRoomSession(
req.user.user_id,
req.user.tenant_id,
roomOptionsFromRequest(req)
room
)
await notifyTenantUsersAboutCall(req, room, callModeFromRequest(req))
return session
} catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix call session failed")
}

View File

@@ -1,31 +1,92 @@
// routes/notifications.routes.ts
import { FastifyInstance } from 'fastify';
import { NotificationService, UserDirectory } from '../modules/notification.service';
import { eq } from "drizzle-orm";
import { authUsers } from "../../db/schema";
import { FastifyInstance } from "fastify"
import { eq } from "drizzle-orm"
import { authUsers } from "../../db/schema"
import { NotificationService, UserDirectory } from "../modules/notification.service"
// Beispiel: E-Mail aus eigener User-Tabelle laden
const getUserDirectory: UserDirectory = async (server:FastifyInstance, userId, tenantId) => {
const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => {
const rows = await server.db
.select({ email: authUsers.email })
.from(authUsers)
.where(eq(authUsers.id, userId))
.limit(1)
const data = rows[0]
if (!data) return null;
return { email: data.email };
};
if (!data) return null
return { email: data.email }
}
const requireTenant = (tenantId: number | null) => {
if (!tenantId) throw new Error("Kein aktiver Mandant")
return tenantId
}
export default async function notificationsRoutes(server: FastifyInstance) {
const svc = new NotificationService(server, getUserDirectory);
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 });
server.get("/notifications", async (req) => {
const limit = Number((req.query as { limit?: string })?.limit || 50)
return await svc.listForUser(requireTenant(req.user.tenant_id), req.user.user_id, limit)
})
server.post("/notifications/:id/read", async (req, reply) => {
const params = req.params as { id: string }
const item = await svc.markRead(requireTenant(req.user.tenant_id), req.user.user_id, params.id)
if (!item) return reply.code(404).send({ error: "Benachrichtigung nicht gefunden" })
return item
})
server.get("/notifications/push/config", async () => {
return svc.getPublicPushConfig()
})
server.post("/notifications/push/subscribe", async (req) => {
const tenantId = requireTenant(req.user.tenant_id)
const userAgent = req.headers["user-agent"]
const subscription = await svc.registerPushSubscription(
tenantId,
req.user.user_id,
req.body as any,
Array.isArray(userAgent) ? userAgent.join(" ") : userAgent
)
return {
success: true,
id: subscription?.id,
}
});
})
server.delete("/notifications/push/subscribe", async (req) => {
const body = (req.body || {}) as { endpoint?: string }
if (!body.endpoint) throw new Error("endpoint fehlt")
return await svc.disablePushSubscription(requireTenant(req.user.tenant_id), req.user.user_id, body.endpoint)
})
server.post("/notifications/test-push", async (req) => {
return await svc.trigger({
tenantId: requireTenant(req.user.tenant_id),
userId: req.user.user_id,
eventType: "system.test_push",
title: "FEDEO Desktop Push ist aktiv",
message: "Diese Testbenachrichtigung wurde von FEDEO selbst zugestellt.",
payload: {
link: "/",
icon: "/favicon.ico",
},
channels: ["inapp", "push"],
})
})
server.post("/notifications/trigger", async (req, reply) => {
try {
const body = req.body as any
const tenantId = body.tenantId || req.user.tenant_id
const res = await svc.trigger({
...body,
tenantId: requireTenant(tenantId),
})
reply.send(res)
} catch (err: any) {
server.log.error(err)
reply.code(500).send({ error: err.message })
}
})
}

View File

@@ -47,6 +47,9 @@ export let secrets = {
MATRIX_SERVICE_USER_LOCALPART?: string
LIVEKIT_KEY?: string
LIVEKIT_SECRET?: string
WEB_PUSH_PUBLIC_KEY?: string
WEB_PUSH_PRIVATE_KEY?: string
WEB_PUSH_SUBJECT?: string
}
const secretKeys = [
@@ -88,6 +91,9 @@ const secretKeys = [
"MATRIX_SERVICE_USER_LOCALPART",
"LIVEKIT_KEY",
"LIVEKIT_SECRET",
"WEB_PUSH_PUBLIC_KEY",
"WEB_PUSH_PRIVATE_KEY",
"WEB_PUSH_SUBJECT",
] as const
const numberKeys = new Set(["PORT", "MAILER_SMTP_PORT", "DOKUBOX_IMAP_PORT"])

View File

@@ -53,6 +53,9 @@ services:
- INFISICAL_CLIENT_ID=a6838bd6-9983-4bf4-9be2-ace830b9abdf
- INFISICAL_CLIENT_SECRET=4e3441acc0adbffd324aa50e668a95a556a3f55ec6bb85954e176e35a3392003
- NODE_ENV=production
- WEB_PUSH_PUBLIC_KEY=${WEB_PUSH_PUBLIC_KEY:-}
- WEB_PUSH_PRIVATE_KEY=${WEB_PUSH_PRIVATE_KEY:-}
- WEB_PUSH_SUBJECT=${WEB_PUSH_SUBJECT:-mailto:admin@example.com}
networks:
- traefik
labels:

View File

@@ -2,6 +2,8 @@
import {formatTimeAgo} from '@vueuse/core'
const { isNotificationsSlideoverOpen } = useDashboard()
const toast = useToast()
const desktopPush = useDesktopPush()
watch(isNotificationsSlideoverOpen, async (newVal,oldVal) => {
if(newVal === true) {
@@ -13,7 +15,8 @@ const notifications = ref([])
const setup = async () => {
try {
notifications.value = await useNuxtApp().$api("/api/resource/notifications_items")
notifications.value = await useNuxtApp().$api("/api/notifications")
await desktopPush.loadConfig()
} catch (e) {
notifications.value = []
}
@@ -23,9 +26,8 @@ setup()
const setNotificationAsRead = async (notification) => {
try {
await useNuxtApp().$api(`/api/resource/notifications_items/${notification.id}`, {
method: "PUT",
body: { readAt: new Date() }
await useNuxtApp().$api(`/api/notifications/${notification.id}/read`, {
method: "POST"
})
} catch (e) {
// noop: endpoint optional in older/newer backend variants
@@ -33,15 +35,78 @@ const setNotificationAsRead = async (notification) => {
setup()
}
const enableDesktopPush = async () => {
const success = await desktopPush.subscribe()
toast.add({
title: success ? "Desktop Push aktiviert" : "Desktop Push konnte nicht aktiviert werden",
description: desktopPush.error.value || undefined,
color: success ? "success" : "error"
})
}
const sendTestPush = async () => {
try {
await desktopPush.sendTestPush()
toast.add({ title: "Testbenachrichtigung gesendet", color: "success" })
await setup()
} catch (error) {
toast.add({
title: "Testbenachrichtigung fehlgeschlagen",
description: error?.data?.error || error?.message,
color: "error"
})
}
}
</script>
<template>
<USlideover v-model:open="isNotificationsSlideoverOpen" title="Benachrichtigungen" side="right">
<template #body>
<div class="mb-4 rounded-md border border-default bg-muted p-3">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-highlighted">
Desktop Push
</p>
<p class="mt-1 text-xs text-muted">
<span v-if="!desktopPush.supported.value">Dieser Browser unterstützt Desktop Push nicht.</span>
<span v-else-if="!desktopPush.configured.value">Desktop Push ist im Backend noch nicht konfiguriert.</span>
<span v-else-if="desktopPush.permission.value === 'granted'">Benachrichtigungen sind im Browser erlaubt.</span>
<span v-else>Aktiviere Browser-Benachrichtigungen für FEDEO.</span>
</p>
</div>
<div class="flex shrink-0 gap-2">
<UButton
icon="i-heroicons-bell-alert"
size="xs"
color="primary"
variant="soft"
:loading="desktopPush.loading.value"
:disabled="!desktopPush.supported.value || !desktopPush.configured.value"
@click="enableDesktopPush"
>
Aktivieren
</UButton>
<UButton
icon="i-heroicons-paper-airplane"
size="xs"
color="neutral"
variant="outline"
:disabled="desktopPush.permission.value !== 'granted'"
@click="sendTestPush"
>
Test
</UButton>
</div>
</div>
</div>
<NuxtLink
v-for="notification in notifications"
:key="notification.id"
:to="notification.link"
:to="notification.payload?.link || notification.link || '/'"
class="p-3 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer flex items-center gap-3 relative"
@click="setNotificationAsRead(notification)"
>

View File

@@ -0,0 +1,89 @@
const base64UrlToUint8Array = (value) => {
const padding = "=".repeat((4 - value.length % 4) % 4)
const base64 = `${value}${padding}`.replace(/-/g, "+").replace(/_/g, "/")
const rawData = window.atob(base64)
const output = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; i += 1) {
output[i] = rawData.charCodeAt(i)
}
return output
}
export const useDesktopPush = () => {
const { $api } = useNuxtApp()
const supported = computed(() =>
process.client &&
"serviceWorker" in navigator &&
"PushManager" in window &&
"Notification" in window
)
const permission = ref(process.client && "Notification" in window ? Notification.permission : "default")
const configured = ref(false)
const loading = ref(false)
const error = ref("")
const loadConfig = async () => {
if (!supported.value) return { configured: false, publicKey: "" }
const config = await $api("/api/notifications/push/config")
configured.value = Boolean(config.configured && config.publicKey)
return config
}
const subscribe = async () => {
error.value = ""
loading.value = true
try {
const config = await loadConfig()
if (!configured.value) {
throw new Error("Desktop Push ist im Backend noch nicht konfiguriert.")
}
const nextPermission = await Notification.requestPermission()
permission.value = nextPermission
if (nextPermission !== "granted") {
throw new Error("Benachrichtigungen wurden im Browser nicht erlaubt.")
}
const registration = await navigator.serviceWorker.register("/fedeo-push-sw.js")
const existingSubscription = await registration.pushManager.getSubscription()
const subscription = existingSubscription || await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: base64UrlToUint8Array(config.publicKey),
})
await $api("/api/notifications/push/subscribe", {
method: "POST",
body: subscription.toJSON(),
})
return true
} catch (err) {
error.value = err?.data?.error || err?.message || "Desktop Push konnte nicht aktiviert werden."
return false
} finally {
loading.value = false
}
}
const sendTestPush = async () => {
return await $api("/api/notifications/test-push", {
method: "POST",
})
}
return {
supported,
permission,
configured,
loading,
error,
loadConfig,
subscribe,
sendTestPush,
}
}

View File

@@ -395,7 +395,8 @@ const openMatrixCall = async (mode = "video") => {
try {
await leaveMatrixCall()
const session = await $api(`${activeRoomEndpoint.value}/call-session`, {
method: "POST"
method: "POST",
body: { mode }
})
matrixCallSession.value = session
await connectMatrixCall(session, { video: mode === "video" })

View File

@@ -0,0 +1,50 @@
self.addEventListener("push", (event) => {
let data = {}
try {
data = event.data ? event.data.json() : {}
} catch (error) {
data = {
title: "FEDEO",
message: event.data?.text() || "Neue Benachrichtigung",
payload: {},
}
}
const payload = data.payload || {}
const title = data.title || "FEDEO"
const options = {
body: data.message || "",
icon: payload.icon || "/favicon.ico",
badge: payload.badge || "/favicon.ico",
data: {
notificationId: data.id,
link: payload.link || "/",
},
}
event.waitUntil(self.registration.showNotification(title, options))
})
self.addEventListener("notificationclick", (event) => {
event.notification.close()
const targetUrl = new URL(event.notification.data?.link || "/", self.location.origin).href
event.waitUntil((async () => {
const windows = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
})
for (const client of windows) {
if ("focus" in client) {
await client.focus()
if ("navigate" in client) await client.navigate(targetUrl)
return
}
}
await self.clients.openWindow(targetUrl)
})())
})