From aa385de635fd860b7df3d73592b2e676c500d9c7 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 21:13:58 +0200 Subject: [PATCH] ST-cmovs862j: Admin test-send route + public/sw.js service worker POST /api/internal/push/test-send: requireAdmin check (redirect bij niet-admin), optioneel body met defaults, roept sendPushToUser aan, 204. public/sw.js: push-handler met showNotification, notificationclick met same-origin guard, focus bestaand venster of openWindow. Co-Authored-By: Claude Sonnet 4.6 --- app/api/internal/push/test-send/route.ts | 30 +++++++++++++++++ public/sw.js | 42 ++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 app/api/internal/push/test-send/route.ts create mode 100644 public/sw.js diff --git a/app/api/internal/push/test-send/route.ts b/app/api/internal/push/test-send/route.ts new file mode 100644 index 0000000..7359f46 --- /dev/null +++ b/app/api/internal/push/test-send/route.ts @@ -0,0 +1,30 @@ +import { z } from 'zod' +import { requireAdmin } from '@/lib/auth-guard' +import { sendPushToUser } from '@/lib/push-server' + +const schema = z.object({ + title: z.string().max(80).optional(), + body: z.string().max(300).optional(), + url: z.string().optional(), +}) + +export async function POST(req: Request) { + const session = await requireAdmin() + + let input: z.infer = {} + try { + const raw = await req.json() + const parsed = schema.safeParse(raw) + if (parsed.success) input = parsed.data + } catch { + // body is optional — use defaults + } + + await sendPushToUser(session.userId, { + title: input.title ?? 'Test push', + body: input.body ?? 'Admin test notification', + url: input.url ?? '/', + }) + + return new Response(null, { status: 204 }) +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..f4ebb07 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,42 @@ +// Service Worker for Web Push notifications (PBI-55) + +self.addEventListener('push', (event) => { + let payload = { title: 'Scrum4Me', body: '', url: '/', tag: undefined } + try { + if (event.data) payload = { ...payload, ...event.data.json() } + } catch (_) {} + + event.waitUntil( + self.registration.showNotification(payload.title, { + body: payload.body, + icon: '/icon-192.png', + badge: '/icon-192.png', + tag: payload.tag, + data: { url: payload.url }, + }) + ) +}) + +self.addEventListener('notificationclick', (event) => { + event.notification.close() + + const rawUrl = event.notification.data?.url || '/' + const targetUrl = new URL(rawUrl, self.location.origin) + + // Same-origin guard + if (targetUrl.origin !== self.location.origin) return + + event.waitUntil( + self.clients + .matchAll({ type: 'window', includeUncontrolled: true }) + .then((clients) => { + for (const client of clients) { + if (client.url.startsWith(self.location.origin) && 'focus' in client) { + client.navigate(targetUrl.href) + return client.focus() + } + } + return self.clients.openWindow(targetUrl.href) + }) + ) +})