KI-AGENT: Mobile Push Registrierung anbinden
This commit is contained in:
19
backend/db/migrations/0048_mobile_push_devices.sql
Normal file
19
backend/db/migrations/0048_mobile_push_devices.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE "notification_mobile_push_devices" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"tenant_id" bigint NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"local_device_id" text NOT NULL,
|
||||
"central_device_id" text NOT NULL,
|
||||
"platform" text NOT NULL,
|
||||
"provider_token_preview" 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
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "notification_mobile_push_devices" ADD CONSTRAINT "notification_mobile_push_devices_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE "notification_mobile_push_devices" ADD CONSTRAINT "notification_mobile_push_devices_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "notification_mobile_push_devices_user_device_key" ON "notification_mobile_push_devices" USING btree ("tenant_id","user_id","local_device_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "notification_mobile_push_devices_central_device_key" ON "notification_mobile_push_devices" USING btree ("central_device_id");
|
||||
@@ -337,6 +337,13 @@
|
||||
"when": 1780171200000,
|
||||
"tag": "0047_telephony_extensions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 48,
|
||||
"version": "7",
|
||||
"when": 1780174800000,
|
||||
"tag": "0048_mobile_push_devices",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export * from "./movements"
|
||||
export * from "./m2m_api_keys"
|
||||
export * from "./notifications_event_types"
|
||||
export * from "./notifications_items"
|
||||
export * from "./notification_mobile_push_devices"
|
||||
export * from "./notifications_preferences"
|
||||
export * from "./notifications_preferences_defaults"
|
||||
export * from "./notification_push_subscriptions"
|
||||
|
||||
53
backend/db/schema/notification_mobile_push_devices.ts
Normal file
53
backend/db/schema/notification_mobile_push_devices.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
bigint,
|
||||
text,
|
||||
jsonb,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
import { tenants } from "./tenants"
|
||||
import { authUsers } from "./auth_users"
|
||||
|
||||
export const notificationMobilePushDevices = pgTable(
|
||||
"notification_mobile_push_devices",
|
||||
{
|
||||
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" }),
|
||||
|
||||
localDeviceId: text("local_device_id").notNull(),
|
||||
centralDeviceId: text("central_device_id").notNull(),
|
||||
platform: text("platform").notNull(),
|
||||
providerTokenPreview: text("provider_token_preview"),
|
||||
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) => ({
|
||||
uniqueUserDevice: uniqueIndex("notification_mobile_push_devices_user_device_key")
|
||||
.on(table.tenantId, table.userId, table.localDeviceId),
|
||||
uniqueCentralDevice: uniqueIndex("notification_mobile_push_devices_central_device_key")
|
||||
.on(table.centralDeviceId),
|
||||
}),
|
||||
)
|
||||
|
||||
export type NotificationMobilePushDevice =
|
||||
typeof notificationMobilePushDevices.$inferSelect
|
||||
export type NewNotificationMobilePushDevice =
|
||||
typeof notificationMobilePushDevices.$inferInsert
|
||||
@@ -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({
|
||||
|
||||
101
backend/src/modules/push-server.client.ts
Normal file
101
backend/src/modules/push-server.client.ts
Normal 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,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { authUsers } from "../../db/schema"
|
||||
import { and, eq, isNull } from "drizzle-orm"
|
||||
import { authUsers, notificationMobilePushDevices } from "../../db/schema"
|
||||
import { NotificationService, UserDirectory } from "../modules/notification.service"
|
||||
import { pushServerClient } from "../modules/push-server.client"
|
||||
|
||||
const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => {
|
||||
const rows = await server.db
|
||||
@@ -60,6 +61,104 @@ export default async function notificationsRoutes(server: FastifyInstance) {
|
||||
return await svc.disablePushSubscription(requireTenant(req.user.tenant_id), req.user.user_id, body.endpoint)
|
||||
})
|
||||
|
||||
server.post("/notifications/push/mobile/register", async (req) => {
|
||||
const tenantId = requireTenant(req.user.tenant_id)
|
||||
const body = (req.body || {}) as {
|
||||
localDeviceId?: string
|
||||
platform?: "ios" | "android"
|
||||
providerToken?: string
|
||||
deviceLabel?: string
|
||||
meta?: Record<string, unknown>
|
||||
}
|
||||
|
||||
if (!body.localDeviceId) throw new Error("localDeviceId fehlt")
|
||||
if (body.platform !== "ios" && body.platform !== "android") throw new Error("platform ist ungültig")
|
||||
if (!body.providerToken) throw new Error("providerToken fehlt")
|
||||
|
||||
const centralLocalDeviceId = `${tenantId}:${req.user.user_id}:${body.localDeviceId}`
|
||||
const registered = await pushServerClient.registerDevice({
|
||||
localDeviceId: centralLocalDeviceId,
|
||||
platform: body.platform,
|
||||
providerToken: body.providerToken,
|
||||
meta: {
|
||||
...(body.meta || {}),
|
||||
tenantId,
|
||||
userId: req.user.user_id,
|
||||
source: "fedeo-mobile",
|
||||
},
|
||||
})
|
||||
|
||||
const rows = await server.db
|
||||
.insert(notificationMobilePushDevices)
|
||||
.values({
|
||||
tenantId,
|
||||
userId: req.user.user_id,
|
||||
localDeviceId: body.localDeviceId,
|
||||
centralDeviceId: registered.centralDeviceId,
|
||||
platform: body.platform,
|
||||
providerTokenPreview: previewToken(body.providerToken),
|
||||
deviceLabel: body.deviceLabel,
|
||||
meta: body.meta ?? null,
|
||||
lastSeenAt: new Date(),
|
||||
disabledAt: null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
notificationMobilePushDevices.tenantId,
|
||||
notificationMobilePushDevices.userId,
|
||||
notificationMobilePushDevices.localDeviceId,
|
||||
],
|
||||
set: {
|
||||
centralDeviceId: registered.centralDeviceId,
|
||||
platform: body.platform,
|
||||
providerTokenPreview: previewToken(body.providerToken),
|
||||
deviceLabel: body.deviceLabel,
|
||||
meta: body.meta ?? null,
|
||||
lastSeenAt: new Date(),
|
||||
disabledAt: null,
|
||||
},
|
||||
})
|
||||
.returning()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
id: rows[0]?.id,
|
||||
centralDeviceId: registered.centralDeviceId,
|
||||
status: registered.status,
|
||||
}
|
||||
})
|
||||
|
||||
server.post("/notifications/test-mobile-push", async (req) => {
|
||||
const tenantId = requireTenant(req.user.tenant_id)
|
||||
const devices = await server.db
|
||||
.select({ centralDeviceId: notificationMobilePushDevices.centralDeviceId })
|
||||
.from(notificationMobilePushDevices)
|
||||
.where(and(
|
||||
eq(notificationMobilePushDevices.tenantId, tenantId),
|
||||
eq(notificationMobilePushDevices.userId, req.user.user_id),
|
||||
isNull(notificationMobilePushDevices.disabledAt)
|
||||
))
|
||||
|
||||
if (!devices.length) {
|
||||
throw new Error("Kein registriertes mobiles Push-Gerät gefunden")
|
||||
}
|
||||
|
||||
return await pushServerClient.sendPush({
|
||||
idempotencyKey: `mobile-test:${tenantId}:${req.user.user_id}:${Date.now()}`,
|
||||
devices: devices.map((device) => device.centralDeviceId),
|
||||
priority: "high",
|
||||
ttlSeconds: 600,
|
||||
notification: {
|
||||
title: "FEDEO Mobile Push ist aktiv",
|
||||
body: "Diese Testnachricht wurde über den zentralen Push-Server zugestellt.",
|
||||
},
|
||||
data: {
|
||||
type: "system.test_mobile_push",
|
||||
link: "/",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
server.post("/notifications/test-push", async (req) => {
|
||||
return await svc.trigger({
|
||||
tenantId: requireTenant(req.user.tenant_id),
|
||||
@@ -90,3 +189,8 @@ export default async function notificationsRoutes(server: FastifyInstance) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function previewToken(token: string) {
|
||||
if (token.length <= 14) return token
|
||||
return `${token.slice(0, 6)}...${token.slice(-6)}`
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ export let secrets = {
|
||||
WEB_PUSH_PUBLIC_KEY?: string
|
||||
WEB_PUSH_PRIVATE_KEY?: string
|
||||
WEB_PUSH_SUBJECT?: string
|
||||
PUSH_SERVER_URL?: string
|
||||
PUSH_SERVER_INSTANCE_ID?: string
|
||||
PUSH_SERVER_SECRET?: string
|
||||
}
|
||||
|
||||
const secretKeys = [
|
||||
@@ -94,6 +97,9 @@ const secretKeys = [
|
||||
"WEB_PUSH_PUBLIC_KEY",
|
||||
"WEB_PUSH_PRIVATE_KEY",
|
||||
"WEB_PUSH_SUBJECT",
|
||||
"PUSH_SERVER_URL",
|
||||
"PUSH_SERVER_INSTANCE_ID",
|
||||
"PUSH_SERVER_SECRET",
|
||||
] as const
|
||||
|
||||
const numberKeys = new Set(["PORT", "MAILER_SMTP_PORT", "DOKUBOX_IMAP_PORT"])
|
||||
|
||||
Reference in New Issue
Block a user