import { FastifyInstance } from "fastify" import { and, eq, isNull } from "drizzle-orm" import { authProfiles, 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 .select({ email: authUsers.email }) .from(authUsers) .where(eq(authUsers.id, userId)) .limit(1) const data = rows[0] if (!data) return null return { email: data.email } } const requireTenant = (tenantId: number | null) => { if (!tenantId) throw new Error("Kein aktiver Mandant") return tenantId } export default async function notificationsRoutes(server: FastifyInstance) { const svc = new NotificationService(server, getUserDirectory) server.get("/notifications", async (req) => { const limit = Number((req.query as { limit?: string })?.limit || 50) return await svc.listForUser(requireTenant(req.user.tenant_id), req.user.user_id, limit) }) server.post("/notifications/:id/read", async (req, reply) => { const params = req.params as { id: string } const item = await svc.markRead(requireTenant(req.user.tenant_id), req.user.user_id, params.id) if (!item) return reply.code(404).send({ error: "Benachrichtigung nicht gefunden" }) return item }) server.get("/notifications/push/config", async () => { return svc.getPublicPushConfig() }) server.post("/notifications/push/subscribe", async (req) => { const tenantId = requireTenant(req.user.tenant_id) const userAgent = req.headers["user-agent"] const subscription = await svc.registerPushSubscription( tenantId, req.user.user_id, req.body as any, Array.isArray(userAgent) ? userAgent.join(" ") : userAgent ) return { success: true, id: subscription?.id, } }) server.delete("/notifications/push/subscribe", async (req) => { const body = (req.body || {}) as { endpoint?: string } if (!body.endpoint) throw new Error("endpoint fehlt") 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), userId: req.user.user_id, eventType: "system.test_push", title: "FEDEO Desktop Push ist aktiv", message: "Diese Testbenachrichtigung wurde von FEDEO selbst zugestellt.", payload: { link: "/", icon: "/favicon.ico", }, channels: ["inapp", "push"], }) }) server.post("/notifications/test-push/profile/:profileId", async (req, reply) => { const tenantId = requireTenant(req.user.tenant_id) const { profileId } = req.params as { profileId: string } const [profile] = await server.db .select({ userId: authProfiles.user_id, }) .from(authProfiles) .where(and( eq(authProfiles.id, profileId), eq(authProfiles.tenant_id, tenantId) )) .limit(1) if (!profile) { return reply.code(404).send({ error: "Mitarbeiter nicht gefunden" }) } if (!profile.userId) { return reply.code(409).send({ error: "Der Mitarbeiter ist noch nicht mit einem Benutzerkonto verknüpft", }) } const devices = await server.db .select({ centralDeviceId: notificationMobilePushDevices.centralDeviceId }) .from(notificationMobilePushDevices) .where(and( eq(notificationMobilePushDevices.tenantId, tenantId), eq(notificationMobilePushDevices.userId, profile.userId), isNull(notificationMobilePushDevices.disabledAt) )) if (!devices.length) { return reply.code(409).send({ error: "Für diesen Mitarbeiter ist kein aktives mobiles Push-Gerät registriert", }) } try { const result = await pushServerClient.sendPush({ idempotencyKey: `profile-mobile-test:${tenantId}:${profile.userId}:${Date.now()}`, devices: devices.map((device) => device.centralDeviceId), priority: "high", ttlSeconds: 600, notification: { title: "FEDEO Push ist aktiv", body: "Diese Testbenachrichtigung wurde einmalig über das Mitarbeiterprofil ausgelöst.", }, data: { type: "system.test_mobile_push", link: "/", }, }) if (result.accepted === 0) { return reply.code(502).send({ error: "Der zentrale Push-Server hat kein Gerät zur Zustellung angenommen", result, }) } return result } catch (error: any) { server.log.error({ err: error, profileId, userId: profile.userId }, "Mitarbeiter-Test-Push fehlgeschlagen") return reply.code(502).send({ error: error?.message || "Der zentrale Push-Server konnte die Nachricht nicht annehmen", }) } }) server.post("/notifications/trigger", async (req, reply) => { try { const body = req.body as any const tenantId = body.tenantId || req.user.tenant_id const res = await svc.trigger({ ...body, tenantId: requireTenant(tenantId), }) reply.send(res) } catch (err: any) { server.log.error(err) reply.code(500).send({ error: err.message }) } }) } function previewToken(token: string) { if (token.length <= 14) return token return `${token.slice(0, 6)}...${token.slice(-6)}` }