KI-AGENT: Zentrale Benachrichtigungsengine mit Desktop Push umsetzen
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
50
backend/db/schema/notification_push_subscriptions.ts
Normal file
50
backend/db/schema/notification_push_subscriptions.ts
Normal 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
10423
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
}
|
||||
|
||||
private nl2br(s: string) {
|
||||
return s.replace(/\n/g, '<br/>');
|
||||
return s.replace(/\n/g, "<br/>")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
89
frontend/composables/useDesktopPush.js
Normal file
89
frontend/composables/useDesktopPush.js
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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" })
|
||||
|
||||
50
frontend/public/fedeo-push-sw.js
Normal file
50
frontend/public/fedeo-push-sw.js
Normal 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)
|
||||
})())
|
||||
})
|
||||
Reference in New Issue
Block a user