diff --git a/push-server/apps/admin/pages/instances/[id].vue b/push-server/apps/admin/pages/instances/[id].vue index 967a1f6..f197f32 100644 --- a/push-server/apps/admin/pages/instances/[id].vue +++ b/push-server/apps/admin/pages/instances/[id].vue @@ -1,11 +1,19 @@ @@ -47,6 +96,54 @@ async function refreshAll() { + + + + Testnachricht + {{ selectedDeviceIds.length || activeDevices.length }} Zielgeräte + + + + + + + + + + + + + Test senden + + + Alle aktiven auswählen + + + Auswahl leeren + + + + + Zielgeräte + + + + {{ device.centralDeviceId }} + + Keine aktiven Geräte vorhanden. + + + + + Geräte diff --git a/push-server/apps/api/src/routes/admin.ts b/push-server/apps/api/src/routes/admin.ts index 6f4b71d..305189e 100644 --- a/push-server/apps/api/src/routes/admin.ts +++ b/push-server/apps/api/src/routes/admin.ts @@ -6,6 +6,7 @@ import { db } from "../db/client.js"; import { requireAdmin } from "../lib/auth.js"; import { encryptSecret } from "../lib/crypto.js"; import { createClientSecret, createPublicId, previewSecret } from "../lib/ids.js"; +import { deliverJob } from "../services/delivery.js"; const createInstanceSchema = z.object({ name: z.string().min(2), @@ -21,6 +22,13 @@ const updateInstanceSchema = createInstanceSchema.partial().extend({ status: z.enum(["active", "blocked", "disabled"]).optional(), }); +const testPushSchema = z.object({ + deviceIds: z.array(z.string().min(1)).max(100).optional(), + title: z.string().min(1).max(120).default("FEDEO Push-Test"), + body: z.string().min(1).max(240).default("Diese Testnachricht wurde über das Push-Admin-Dashboard gesendet."), + priority: z.enum(["normal", "high"]).default("high"), +}); + export async function adminRoutes(app: FastifyInstance): Promise { app.addHook("preHandler", requireAdmin); @@ -122,6 +130,72 @@ export async function adminRoutes(app: FastifyInstance): Promise { return await db.select().from(deliveryJobs).where(eq(deliveryJobs.instanceId, params.id)).orderBy(desc(deliveryJobs.createdAt)).limit(100); }); + app.post("/admin/instances/:id/test-push", async (request, reply) => { + const params = z.object({ id: z.string().uuid() }).parse(request.params); + const body = testPushSchema.parse(request.body || {}); + const [instance] = await db.select().from(pushInstances).where(eq(pushInstances.id, params.id)).limit(1); + if (!instance) return reply.code(404).send({ error: "instance_not_found" }); + + const devices = await db + .select() + .from(pushDevices) + .where(and(eq(pushDevices.instanceId, instance.id), eq(pushDevices.status, "active"))); + const requested = body.deviceIds?.length ? new Set(body.deviceIds) : null; + const acceptedDevices = devices + .map((device) => device.centralDeviceId) + .filter((centralDeviceId) => !requested || requested.has(centralDeviceId)); + + if (!acceptedDevices.length) { + return reply.code(400).send({ error: "no_active_devices", message: "Für diese Auswahl wurden keine aktiven Geräte gefunden." }); + } + + const rejected = requested ? body.deviceIds!.length - acceptedDevices.length : 0; + const [job] = await db.insert(deliveryJobs).values({ + deliveryJobId: createPublicId("job"), + instanceId: instance.id, + idempotencyKey: `admin-test:${instance.id}:${Date.now()}`, + priority: body.priority, + ttlSeconds: 600, + collapseKey: "admin-test", + acceptedCount: acceptedDevices.length, + rejectedCount: rejected, + status: "processing", + }).returning(); + + await deliverJob(job, acceptedDevices, { + priority: body.priority, + ttlSeconds: 600, + collapseKey: "admin-test", + notification: { + title: body.title, + body: body.body, + }, + data: { + type: "admin.test_push", + deliveryJobId: job.deliveryJobId, + instanceId: instance.instanceId, + }, + }); + + await audit("admin", "instance.test_push.sent", instance.id, { + deliveryJobId: job.deliveryJobId, + accepted: acceptedDevices.length, + rejected, + }); + + const [updatedJob] = await db.select().from(deliveryJobs).where(eq(deliveryJobs.id, job.id)).limit(1); + return reply.code(202).send({ + deliveryJobId: job.deliveryJobId, + accepted: acceptedDevices.length, + rejected, + status: updatedJob?.status || "processing", + sent: updatedJob?.sentCount || 0, + failed: updatedJob?.failedCount || 0, + lastErrorCode: updatedJob?.lastErrorCode, + lastErrorMessage: updatedJob?.lastErrorMessage, + }); + }); + app.get("/admin/jobs", async () => { return await db.select({ id: deliveryJobs.id,
Zielgeräte
Keine aktiven Geräte vorhanden.