KI-AGENT: Mobile Push Registrierung anbinden

This commit is contained in:
2026-05-22 17:34:52 +02:00
parent 5400fd7ad5
commit cacfce4d15
14 changed files with 630 additions and 20 deletions

View File

@@ -3,6 +3,7 @@ import webPush from "web-push"
import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm"
import {
authUsers,
notificationMobilePushDevices,
notificationPushSubscriptions,
notificationsEventTypes,
notificationsItems,
@@ -10,6 +11,7 @@ import {
notificationsPreferencesDefaults,
} from "../../db/schema"
import { secrets } from "../utils/secrets"
import { pushServerClient } from "./push-server.client"
export type NotificationChannel = "inapp" | "email" | "push" | "webhook" | "sms"
export type NotificationStatus = "queued" | "sent" | "failed" | "read"
@@ -280,18 +282,8 @@ export class NotificationService {
}
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
const subscriptions = secrets.WEB_PUSH_PUBLIC_KEY && secrets.WEB_PUSH_PRIVATE_KEY
? await this.server.db
.select()
.from(notificationPushSubscriptions)
.where(and(
@@ -299,8 +291,18 @@ export class NotificationService {
eq(notificationPushSubscriptions.userId, item.userId),
isNull(notificationPushSubscriptions.disabledAt)
))
: []
if (!subscriptions.length) {
const mobileDevices = await this.server.db
.select({ centralDeviceId: notificationMobilePushDevices.centralDeviceId })
.from(notificationMobilePushDevices)
.where(and(
eq(notificationMobilePushDevices.tenantId, item.tenantId),
eq(notificationMobilePushDevices.userId, item.userId),
isNull(notificationMobilePushDevices.disabledAt)
))
if (!subscriptions.length && !mobileDevices.length) {
await this.markFailed(item.id, "Keine aktive Push-Subscription")
return { success: false, id: item.id, channel: item.channel }
}
@@ -315,6 +317,37 @@ export class NotificationService {
let delivered = 0
const errors: string[] = []
if (mobileDevices.length) {
try {
const result = await pushServerClient.sendPush({
idempotencyKey: `notification:${item.id}`,
devices: mobileDevices.map((device) => device.centralDeviceId),
priority: "high",
ttlSeconds: 3600,
notification: {
title: item.title,
body: item.message,
},
data: {
notificationId: item.id,
...(typeof item.payload === "object" && item.payload !== null ? item.payload as Record<string, unknown> : {}),
},
})
delivered += result.accepted
if (result.rejected) errors.push(`${result.rejected} mobile Geräte vom Push-Server abgelehnt`)
} catch (error: any) {
errors.push(error?.message || String(error))
}
}
if (subscriptions.length) {
webPush.setVapidDetails(
secrets.WEB_PUSH_SUBJECT || "mailto:admin@example.com",
secrets.WEB_PUSH_PUBLIC_KEY!,
secrets.WEB_PUSH_PRIVATE_KEY!
)
}
for (const subscription of subscriptions) {
try {
await webPush.sendNotification({

View File

@@ -0,0 +1,101 @@
import { createHash, createHmac } from "node:crypto"
import { secrets } from "../utils/secrets"
type PushServerDevicePlatform = "web" | "ios" | "android"
export type RegisterPushServerDeviceInput = {
localDeviceId: string
platform: PushServerDevicePlatform
providerToken?: string
subscription?: Record<string, unknown>
meta?: Record<string, unknown>
}
export type RegisterPushServerDeviceResult = {
centralDeviceId: string
status: string
}
export type SendPushServerMessageInput = {
idempotencyKey: string
devices: string[]
priority?: "normal" | "high"
ttlSeconds?: number
collapseKey?: string
notification?: {
title?: string
body?: string
}
data?: Record<string, unknown>
}
function configured() {
return Boolean(secrets.PUSH_SERVER_URL && secrets.PUSH_SERVER_INSTANCE_ID && secrets.PUSH_SERVER_SECRET)
}
function normalizeBaseUrl() {
return String(secrets.PUSH_SERVER_URL || "").replace(/\/+$/, "")
}
function bodyHash(body: string) {
return createHash("sha256").update(body).digest("hex")
}
function signature(method: string, path: string, timestamp: string, body: string) {
const canonical = [
method.toUpperCase(),
path,
timestamp,
bodyHash(body),
secrets.PUSH_SERVER_INSTANCE_ID,
].join("\n")
return createHmac("sha256", secrets.PUSH_SERVER_SECRET).update(canonical).digest("hex")
}
async function requestPushServer<T>(method: "POST" | "DELETE", path: string, payload?: unknown): Promise<T> {
if (!configured()) {
throw new Error("Zentraler Push-Server ist nicht konfiguriert")
}
const body = payload === undefined ? "" : JSON.stringify(payload)
const timestamp = new Date().toISOString()
const response = await fetch(`${normalizeBaseUrl()}${path}`, {
method,
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"X-Fedeo-Instance-Id": secrets.PUSH_SERVER_INSTANCE_ID,
"X-Fedeo-Timestamp": timestamp,
"X-Fedeo-Signature": signature(method, path, timestamp, body),
},
body: body || undefined,
})
const text = await response.text()
const data = text ? JSON.parse(text) : null
if (!response.ok) {
const message = data?.message || data?.error || `Push-Server Anfrage fehlgeschlagen (${response.status})`
throw new Error(message)
}
return data as T
}
export const pushServerClient = {
configured,
registerDevice(input: RegisterPushServerDeviceInput) {
return requestPushServer<RegisterPushServerDeviceResult>("POST", "/v1/devices", input)
},
sendPush(input: SendPushServerMessageInput) {
return requestPushServer<{ accepted: number; rejected: number; deliveryJobId: string }>("POST", "/v1/push", {
priority: "normal",
ttlSeconds: 3600,
...input,
})
},
}