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

@@ -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)
})())
})