Files
FEDEO/backend/src/routes/notifications.ts
flfeders 616bb5d7f3
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 43s
Build and Push Docker Images / build-website (push) Successful in 23s
Build and Push Docker Images / build-central-services-admin (push) Successful in 23s
Build and Push Docker Images / build-frontend (push) Successful in 1m12s
Build and Push Docker Images / build-central-services-api (push) Successful in 22s
Build and Push Docker Images / build-docs (push) Successful in 22s
fix: send employee test push via mobile service
2026-09-09 13:08:17 +02:00

268 lines
10 KiB
TypeScript

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<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),
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)}`
}