diff --git a/backend/db/migrations/0048_mobile_push_devices.sql b/backend/db/migrations/0048_mobile_push_devices.sql new file mode 100644 index 0000000..f2ad8a9 --- /dev/null +++ b/backend/db/migrations/0048_mobile_push_devices.sql @@ -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"); diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index ab3a019..7d702c2 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -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 } ] } diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts index b082860..8c03b98 100644 --- a/backend/db/schema/index.ts +++ b/backend/db/schema/index.ts @@ -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" diff --git a/backend/db/schema/notification_mobile_push_devices.ts b/backend/db/schema/notification_mobile_push_devices.ts new file mode 100644 index 0000000..c377933 --- /dev/null +++ b/backend/db/schema/notification_mobile_push_devices.ts @@ -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 diff --git a/backend/src/modules/notification.service.ts b/backend/src/modules/notification.service.ts index 31f05bf..e5f2d40 100644 --- a/backend/src/modules/notification.service.ts +++ b/backend/src/modules/notification.service.ts @@ -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 : {}), + }, + }) + 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({ diff --git a/backend/src/modules/push-server.client.ts b/backend/src/modules/push-server.client.ts new file mode 100644 index 0000000..3413609 --- /dev/null +++ b/backend/src/modules/push-server.client.ts @@ -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 + meta?: Record +} + +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 +} + +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(method: "POST" | "DELETE", path: string, payload?: unknown): Promise { + 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("POST", "/v1/devices", input) + }, + + sendPush(input: SendPushServerMessageInput) { + return requestPushServer<{ accepted: number; rejected: number; deliveryJobId: string }>("POST", "/v1/push", { + priority: "normal", + ttlSeconds: 3600, + ...input, + }) + }, +} diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts index 97147fb..2fe494c 100644 --- a/backend/src/routes/notifications.ts +++ b/backend/src/routes/notifications.ts @@ -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 + } + + 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)}` +} diff --git a/backend/src/utils/secrets.ts b/backend/src/utils/secrets.ts index e878967..62c77d5 100644 --- a/backend/src/utils/secrets.ts +++ b/backend/src/utils/secrets.ts @@ -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"]) diff --git a/mobile/app.json b/mobile/app.json index b699cf0..f2abf76 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -55,6 +55,7 @@ } ], "react-native-ble-plx", + "expo-notifications", "expo-web-browser" ], "experiments": { diff --git a/mobile/app/more/settings.tsx b/mobile/app/more/settings.tsx index 52ae896..e6f3382 100644 --- a/mobile/app/more/settings.tsx +++ b/mobile/app/more/settings.tsx @@ -3,6 +3,8 @@ import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react- import { router } from 'expo-router'; import { DEFAULT_API_BASE_URL } from '@/src/config/env'; +import { sendMobileTestPush } from '@/src/lib/api'; +import { registerDeviceForPush } from '@/src/lib/push-registration'; import { getApiBaseUrlSync, hydrateApiBaseUrl, @@ -23,6 +25,7 @@ export default function SettingsScreen() { const [serverUrl, setServerUrlInput] = useState(getApiBaseUrlSync()); const [savedUrl, setSavedUrl] = useState(getApiBaseUrlSync()); const [submitting, setSubmitting] = useState(false); + const [pushSubmitting, setPushSubmitting] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(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; return ( @@ -131,6 +174,30 @@ export default function SettingsScreen() { + + + Mobile Push + + Registriert dieses Gerät bei deiner FEDEO-Instanz und leitet den nativen Push-Token an den zentralen + Push-Server weiter. + + + + + {pushSubmitting ? 'Bitte warten...' : 'Gerät registrieren'} + + + + Testnachricht senden + + + ); } @@ -140,6 +207,7 @@ const styles = StyleSheet.create({ flexGrow: 1, backgroundColor: '#f9fafb', padding: 16, + gap: 12, }, card: { backgroundColor: '#ffffff', diff --git a/mobile/package-lock.json b/mobile/package-lock.json index d884398..0783109 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -21,6 +21,7 @@ "expo-image": "~3.0.11", "expo-image-picker": "~17.0.11", "expo-linking": "~8.0.12", + "expo-notifications": "^56.0.12", "expo-router": "~6.0.23", "expo-secure-store": "^15.0.8", "expo-splash-screen": "~31.0.13", @@ -2149,6 +2150,25 @@ "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": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz", @@ -2162,12 +2182,12 @@ "license": "MIT" }, "node_modules/@expo/spawn-async": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.7.2.tgz", - "integrity": "sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", + "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.3" + "cross-spawn": "^7.0.6" }, "engines": { "node": ">=12" @@ -4441,6 +4461,12 @@ "@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": { "version": "1.0.2", "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": { "version": "12.0.13", "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.13.tgz", @@ -6225,6 +6260,78 @@ "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": { "version": "6.0.23", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.23.tgz", @@ -12086,7 +12193,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/mobile/package.json b/mobile/package.json index ed0d375..1d8f6c1 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -34,6 +34,7 @@ "expo-image": "~3.0.11", "expo-image-picker": "~17.0.11", "expo-linking": "~8.0.12", + "expo-notifications": "^56.0.12", "expo-router": "~6.0.23", "expo-secure-store": "^15.0.8", "expo-splash-screen": "~31.0.13", diff --git a/mobile/src/lib/api.ts b/mobile/src/lib/api.ts index 7efd3bb..2a579db 100644 --- a/mobile/src/lib/api.ts +++ b/mobile/src/lib/api.ts @@ -150,6 +150,21 @@ export type PrintLabelResponse = { base64?: string; }; +export type MobilePushRegistrationInput = { + localDeviceId: string; + platform: 'ios' | 'android'; + providerToken: string; + deviceLabel?: string | null; + meta?: Record; +}; + +export type MobilePushRegistrationResponse = { + success: boolean; + id?: string; + centralDeviceId?: string; + status?: string; +}; + export type WikiTreeItem = { id: string; parentId?: string | null; @@ -582,6 +597,24 @@ export async function fetchMe(token: string): Promise { }); } +export async function registerMobilePushDevice( + token: string, + payload: MobilePushRegistrationInput +): Promise { + return apiRequest('/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 { const payload = await apiRequest<{ token?: string }>('/api/tenant/switch', { method: 'POST', diff --git a/mobile/src/lib/push-registration.ts b/mobile/src/lib/push-registration.ts new file mode 100644 index 0000000..f0b1344 --- /dev/null +++ b/mobile/src/lib/push-registration.ts @@ -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, + }, + }); +}