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,
|
"when": 1780171200000,
|
||||||
"tag": "0047_telephony_extensions",
|
"tag": "0047_telephony_extensions",
|
||||||
"breakpoints": true
|
"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 "./m2m_api_keys"
|
||||||
export * from "./notifications_event_types"
|
export * from "./notifications_event_types"
|
||||||
export * from "./notifications_items"
|
export * from "./notifications_items"
|
||||||
|
export * from "./notification_mobile_push_devices"
|
||||||
export * from "./notifications_preferences"
|
export * from "./notifications_preferences"
|
||||||
export * from "./notifications_preferences_defaults"
|
export * from "./notifications_preferences_defaults"
|
||||||
export * from "./notification_push_subscriptions"
|
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 { and, desc, eq, inArray, isNull, sql } from "drizzle-orm"
|
||||||
import {
|
import {
|
||||||
authUsers,
|
authUsers,
|
||||||
|
notificationMobilePushDevices,
|
||||||
notificationPushSubscriptions,
|
notificationPushSubscriptions,
|
||||||
notificationsEventTypes,
|
notificationsEventTypes,
|
||||||
notificationsItems,
|
notificationsItems,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
notificationsPreferencesDefaults,
|
notificationsPreferencesDefaults,
|
||||||
} from "../../db/schema"
|
} from "../../db/schema"
|
||||||
import { secrets } from "../utils/secrets"
|
import { secrets } from "../utils/secrets"
|
||||||
|
import { pushServerClient } from "./push-server.client"
|
||||||
|
|
||||||
export type NotificationChannel = "inapp" | "email" | "push" | "webhook" | "sms"
|
export type NotificationChannel = "inapp" | "email" | "push" | "webhook" | "sms"
|
||||||
export type NotificationStatus = "queued" | "sent" | "failed" | "read"
|
export type NotificationStatus = "queued" | "sent" | "failed" | "read"
|
||||||
@@ -280,18 +282,8 @@ export class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async deliverPush(item: typeof notificationsItems.$inferSelect) {
|
private async deliverPush(item: typeof notificationsItems.$inferSelect) {
|
||||||
if (!secrets.WEB_PUSH_PUBLIC_KEY || !secrets.WEB_PUSH_PRIVATE_KEY) {
|
const subscriptions = secrets.WEB_PUSH_PUBLIC_KEY && secrets.WEB_PUSH_PRIVATE_KEY
|
||||||
await this.markFailed(item.id, "Web Push ist nicht konfiguriert")
|
? await this.server.db
|
||||||
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()
|
.select()
|
||||||
.from(notificationPushSubscriptions)
|
.from(notificationPushSubscriptions)
|
||||||
.where(and(
|
.where(and(
|
||||||
@@ -299,8 +291,18 @@ export class NotificationService {
|
|||||||
eq(notificationPushSubscriptions.userId, item.userId),
|
eq(notificationPushSubscriptions.userId, item.userId),
|
||||||
isNull(notificationPushSubscriptions.disabledAt)
|
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")
|
await this.markFailed(item.id, "Keine aktive Push-Subscription")
|
||||||
return { success: false, id: item.id, channel: item.channel }
|
return { success: false, id: item.id, channel: item.channel }
|
||||||
}
|
}
|
||||||
@@ -315,6 +317,37 @@ export class NotificationService {
|
|||||||
let delivered = 0
|
let delivered = 0
|
||||||
const errors: string[] = []
|
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) {
|
for (const subscription of subscriptions) {
|
||||||
try {
|
try {
|
||||||
await webPush.sendNotification({
|
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 { FastifyInstance } from "fastify"
|
||||||
import { eq } from "drizzle-orm"
|
import { and, eq, isNull } from "drizzle-orm"
|
||||||
import { authUsers } from "../../db/schema"
|
import { authUsers, notificationMobilePushDevices } from "../../db/schema"
|
||||||
import { NotificationService, UserDirectory } from "../modules/notification.service"
|
import { NotificationService, UserDirectory } from "../modules/notification.service"
|
||||||
|
import { pushServerClient } from "../modules/push-server.client"
|
||||||
|
|
||||||
const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => {
|
const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => {
|
||||||
const rows = await server.db
|
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)
|
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) => {
|
server.post("/notifications/test-push", async (req) => {
|
||||||
return await svc.trigger({
|
return await svc.trigger({
|
||||||
tenantId: requireTenant(req.user.tenant_id),
|
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_PUBLIC_KEY?: string
|
||||||
WEB_PUSH_PRIVATE_KEY?: string
|
WEB_PUSH_PRIVATE_KEY?: string
|
||||||
WEB_PUSH_SUBJECT?: string
|
WEB_PUSH_SUBJECT?: string
|
||||||
|
PUSH_SERVER_URL?: string
|
||||||
|
PUSH_SERVER_INSTANCE_ID?: string
|
||||||
|
PUSH_SERVER_SECRET?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const secretKeys = [
|
const secretKeys = [
|
||||||
@@ -94,6 +97,9 @@ const secretKeys = [
|
|||||||
"WEB_PUSH_PUBLIC_KEY",
|
"WEB_PUSH_PUBLIC_KEY",
|
||||||
"WEB_PUSH_PRIVATE_KEY",
|
"WEB_PUSH_PRIVATE_KEY",
|
||||||
"WEB_PUSH_SUBJECT",
|
"WEB_PUSH_SUBJECT",
|
||||||
|
"PUSH_SERVER_URL",
|
||||||
|
"PUSH_SERVER_INSTANCE_ID",
|
||||||
|
"PUSH_SERVER_SECRET",
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const numberKeys = new Set(["PORT", "MAILER_SMTP_PORT", "DOKUBOX_IMAP_PORT"])
|
const numberKeys = new Set(["PORT", "MAILER_SMTP_PORT", "DOKUBOX_IMAP_PORT"])
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"react-native-ble-plx",
|
"react-native-ble-plx",
|
||||||
|
"expo-notifications",
|
||||||
"expo-web-browser"
|
"expo-web-browser"
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-
|
|||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
|
|
||||||
import { DEFAULT_API_BASE_URL } from '@/src/config/env';
|
import { DEFAULT_API_BASE_URL } from '@/src/config/env';
|
||||||
|
import { sendMobileTestPush } from '@/src/lib/api';
|
||||||
|
import { registerDeviceForPush } from '@/src/lib/push-registration';
|
||||||
import {
|
import {
|
||||||
getApiBaseUrlSync,
|
getApiBaseUrlSync,
|
||||||
hydrateApiBaseUrl,
|
hydrateApiBaseUrl,
|
||||||
@@ -23,6 +25,7 @@ export default function SettingsScreen() {
|
|||||||
const [serverUrl, setServerUrlInput] = useState(getApiBaseUrlSync());
|
const [serverUrl, setServerUrlInput] = useState(getApiBaseUrlSync());
|
||||||
const [savedUrl, setSavedUrl] = useState(getApiBaseUrlSync());
|
const [savedUrl, setSavedUrl] = useState(getApiBaseUrlSync());
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [pushSubmitting, setPushSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -85,6 +88,46 @@ export default function SettingsScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onRegisterPush() {
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
setError('Bitte zuerst anmelden, um dieses Gerät für Push zu registrieren.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPushSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await registerDeviceForPush(token);
|
||||||
|
setSuccess(`Push registriert: ${result.centralDeviceId || result.id || 'aktiv'}`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Push-Registrierung fehlgeschlagen.');
|
||||||
|
} finally {
|
||||||
|
setPushSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSendTestPush() {
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
setError('Bitte zuerst anmelden, um eine Testnachricht zu senden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPushSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await sendMobileTestPush(token);
|
||||||
|
setSuccess(`Testnachricht angefordert: ${result.accepted} akzeptiert, ${result.rejected} abgelehnt.`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Testnachricht konnte nicht gesendet werden.');
|
||||||
|
} finally {
|
||||||
|
setPushSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isDirty = serverUrl.trim() !== savedUrl;
|
const isDirty = serverUrl.trim() !== savedUrl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -131,6 +174,30 @@ export default function SettingsScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.title}>Mobile Push</Text>
|
||||||
|
<Text style={styles.hint}>
|
||||||
|
Registriert dieses Gerät bei deiner FEDEO-Instanz und leitet den nativen Push-Token an den zentralen
|
||||||
|
Push-Server weiter.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.saveButton, (!token || pushSubmitting) ? styles.buttonDisabled : null]}
|
||||||
|
onPress={onRegisterPush}
|
||||||
|
disabled={!token || pushSubmitting}>
|
||||||
|
<Text style={styles.saveButtonText}>{pushSubmitting ? 'Bitte warten...' : 'Gerät registrieren'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={[styles.resetButton, (!token || pushSubmitting) ? styles.buttonDisabled : null]}
|
||||||
|
onPress={onSendTestPush}
|
||||||
|
disabled={!token || pushSubmitting}>
|
||||||
|
<Text style={styles.resetButtonText}>Testnachricht senden</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -140,6 +207,7 @@ const styles = StyleSheet.create({
|
|||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
backgroundColor: '#f9fafb',
|
backgroundColor: '#f9fafb',
|
||||||
padding: 16,
|
padding: 16,
|
||||||
|
gap: 12,
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
|
|||||||
117
mobile/package-lock.json
generated
117
mobile/package-lock.json
generated
@@ -21,6 +21,7 @@
|
|||||||
"expo-image": "~3.0.11",
|
"expo-image": "~3.0.11",
|
||||||
"expo-image-picker": "~17.0.11",
|
"expo-image-picker": "~17.0.11",
|
||||||
"expo-linking": "~8.0.12",
|
"expo-linking": "~8.0.12",
|
||||||
|
"expo-notifications": "^56.0.12",
|
||||||
"expo-router": "~6.0.23",
|
"expo-router": "~6.0.23",
|
||||||
"expo-secure-store": "^15.0.8",
|
"expo-secure-store": "^15.0.8",
|
||||||
"expo-splash-screen": "~31.0.13",
|
"expo-splash-screen": "~31.0.13",
|
||||||
@@ -2149,6 +2150,25 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@expo/require-utils": {
|
||||||
|
"version": "56.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.2.tgz",
|
||||||
|
"integrity": "sha512-j+zlUQK7xPTKlR9honSLN4umd4czOpNBPibJhOQVxSfT3IP8UJR+7aFvccj5dbuYiBCzDy8vxuDm3AGa0onR8Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/code-frame": "^7.20.0",
|
||||||
|
"@babel/core": "^7.25.2",
|
||||||
|
"@babel/plugin-transform-modules-commonjs": "^7.24.8"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@expo/schema-utils": {
|
"node_modules/@expo/schema-utils": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz",
|
||||||
@@ -2162,12 +2182,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@expo/spawn-async": {
|
"node_modules/@expo/spawn-async": {
|
||||||
"version": "1.7.2",
|
"version": "1.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz",
|
||||||
"integrity": "sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==",
|
"integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "^7.0.3"
|
"cross-spawn": "^7.0.6"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -4441,6 +4461,12 @@
|
|||||||
"@babel/core": "^7.0.0"
|
"@babel/core": "^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/badgin": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -6043,6 +6069,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-application": {
|
||||||
|
"version": "56.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-56.0.3.tgz",
|
||||||
|
"integrity": "sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-asset": {
|
"node_modules/expo-asset": {
|
||||||
"version": "12.0.13",
|
"version": "12.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.13.tgz",
|
||||||
@@ -6225,6 +6260,78 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-notifications": {
|
||||||
|
"version": "56.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-56.0.12.tgz",
|
||||||
|
"integrity": "sha512-ZGFeA6vs1dt+9IcFtriIf2sEgBSEXGZ6OnWIYzUkdYqKpJFv1/zigUyquAMEvGbAAjGC0Uwf8qXNYJc1pyxFfA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/image-utils": "^0.10.0",
|
||||||
|
"abort-controller": "^3.0.0",
|
||||||
|
"badgin": "^1.1.5",
|
||||||
|
"expo-application": "~56.0.3",
|
||||||
|
"expo-constants": "~56.0.14"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-notifications/node_modules/@expo/env": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@expo/env/-/env-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chalk": "^4.0.0",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"getenv": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-notifications/node_modules/@expo/image-utils": {
|
||||||
|
"version": "0.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.10.0.tgz",
|
||||||
|
"integrity": "sha512-iV1J+F5KpVqfdYsuot+5b8ZBDH6m/jQN2EzQSoa+qOmHqPNck17AihA4X3sso7ghn7p+AHeOKgftwT64amgmkQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/require-utils": "^56.1.2",
|
||||||
|
"@expo/spawn-async": "^1.8.0",
|
||||||
|
"chalk": "^4.0.0",
|
||||||
|
"getenv": "^2.0.0",
|
||||||
|
"jimp-compact": "0.16.1",
|
||||||
|
"parse-png": "^2.1.0",
|
||||||
|
"semver": "^7.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-notifications/node_modules/expo-constants": {
|
||||||
|
"version": "56.0.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.14.tgz",
|
||||||
|
"integrity": "sha512-NeFIFXi+RAB5ayR/CPiQXRab0HczkA+BQfF8uci4G3RMBSy+uzd+1skRx/uqLUo3OYjSfs6LUQ8JDVbRgJRRQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/env": "~2.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-notifications/node_modules/semver": {
|
||||||
|
"version": "7.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||||
|
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-router": {
|
"node_modules/expo-router": {
|
||||||
"version": "6.0.23",
|
"version": "6.0.23",
|
||||||
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.23.tgz",
|
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.23.tgz",
|
||||||
@@ -12086,7 +12193,7 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"expo-image": "~3.0.11",
|
"expo-image": "~3.0.11",
|
||||||
"expo-image-picker": "~17.0.11",
|
"expo-image-picker": "~17.0.11",
|
||||||
"expo-linking": "~8.0.12",
|
"expo-linking": "~8.0.12",
|
||||||
|
"expo-notifications": "^56.0.12",
|
||||||
"expo-router": "~6.0.23",
|
"expo-router": "~6.0.23",
|
||||||
"expo-secure-store": "^15.0.8",
|
"expo-secure-store": "^15.0.8",
|
||||||
"expo-splash-screen": "~31.0.13",
|
"expo-splash-screen": "~31.0.13",
|
||||||
|
|||||||
@@ -150,6 +150,21 @@ export type PrintLabelResponse = {
|
|||||||
base64?: string;
|
base64?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MobilePushRegistrationInput = {
|
||||||
|
localDeviceId: string;
|
||||||
|
platform: 'ios' | 'android';
|
||||||
|
providerToken: string;
|
||||||
|
deviceLabel?: string | null;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MobilePushRegistrationResponse = {
|
||||||
|
success: boolean;
|
||||||
|
id?: string;
|
||||||
|
centralDeviceId?: string;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type WikiTreeItem = {
|
export type WikiTreeItem = {
|
||||||
id: string;
|
id: string;
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
@@ -582,6 +597,24 @@ export async function fetchMe(token: string): Promise<MeResponse> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function registerMobilePushDevice(
|
||||||
|
token: string,
|
||||||
|
payload: MobilePushRegistrationInput
|
||||||
|
): Promise<MobilePushRegistrationResponse> {
|
||||||
|
return apiRequest<MobilePushRegistrationResponse>('/api/notifications/push/mobile/register', {
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMobileTestPush(token: string): Promise<{ accepted: number; rejected: number; deliveryJobId: string }> {
|
||||||
|
return apiRequest<{ accepted: number; rejected: number; deliveryJobId: string }>('/api/notifications/test-mobile-push', {
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function switchTenantRequest(tenantId: number, token: string): Promise<string> {
|
export async function switchTenantRequest(tenantId: number, token: string): Promise<string> {
|
||||||
const payload = await apiRequest<{ token?: string }>('/api/tenant/switch', {
|
const payload = await apiRequest<{ token?: string }>('/api/tenant/switch', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
76
mobile/src/lib/push-registration.ts
Normal file
76
mobile/src/lib/push-registration.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import Constants from 'expo-constants';
|
||||||
|
import * as Notifications from 'expo-notifications';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
import { registerMobilePushDevice } from '@/src/lib/api';
|
||||||
|
|
||||||
|
const DEVICE_ID_KEY = 'fedeo.mobilePush.localDeviceId';
|
||||||
|
|
||||||
|
Notifications.setNotificationHandler({
|
||||||
|
handleNotification: async () => ({
|
||||||
|
shouldPlaySound: true,
|
||||||
|
shouldSetBadge: true,
|
||||||
|
shouldShowBanner: true,
|
||||||
|
shouldShowList: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function createLocalDeviceId(): string {
|
||||||
|
const random = typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
|
return `mobile-${random}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLocalDeviceId() {
|
||||||
|
const existing = await SecureStore.getItemAsync(DEVICE_ID_KEY);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const created = createLocalDeviceId();
|
||||||
|
await SecureStore.setItemAsync(DEVICE_ID_KEY, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePushPermission() {
|
||||||
|
const current = await Notifications.getPermissionsAsync();
|
||||||
|
if (hasPushPermission(current)) return;
|
||||||
|
|
||||||
|
const requested = await Notifications.requestPermissionsAsync();
|
||||||
|
if (!hasPushPermission(requested)) {
|
||||||
|
throw new Error('Push-Mitteilungen wurden nicht erlaubt.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPushPermission(status: unknown): boolean {
|
||||||
|
const permission = status as { granted?: boolean; status?: string };
|
||||||
|
return permission.granted === true || permission.status === 'granted';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerDeviceForPush(token: string) {
|
||||||
|
if (Platform.OS !== 'ios' && Platform.OS !== 'android') {
|
||||||
|
throw new Error('Mobile Push ist nur auf iOS und Android verfügbar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensurePushPermission();
|
||||||
|
|
||||||
|
const deviceToken = await Notifications.getDevicePushTokenAsync();
|
||||||
|
const providerToken = String(deviceToken.data || '');
|
||||||
|
if (!providerToken) {
|
||||||
|
throw new Error('Es wurde kein nativer Push-Token zurückgegeben.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await registerMobilePushDevice(token, {
|
||||||
|
localDeviceId: await getLocalDeviceId(),
|
||||||
|
platform: Platform.OS,
|
||||||
|
providerToken,
|
||||||
|
deviceLabel: Constants.deviceName || `${Platform.OS} Gerät`,
|
||||||
|
meta: {
|
||||||
|
os: Platform.OS,
|
||||||
|
osVersion: Platform.Version,
|
||||||
|
expoRuntimeVersion: Constants.expoConfig?.runtimeVersion || null,
|
||||||
|
appVersion: Constants.expoConfig?.version || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user