ST-cmovs7jgr: lib/push-server.ts met sendPushToUser + stale-cleanup

Server-only push-lib met VAPID feature-gate, send naar alle
subscriptions van een user, en automatische cleanup bij 404/410.
Unit tests: success-pad, 410 verwijdert sub, 404 verwijdert sub,
andere errors loggen zonder delete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scrum4Me Agent 2026-05-07 20:58:18 +02:00
parent 2f5ea553bc
commit 880a3097af
2 changed files with 143 additions and 0 deletions

View file

@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('server-only', () => ({}))
const { mockSendNotification } = vi.hoisted(() => ({
mockSendNotification: vi.fn(),
}))
vi.mock('web-push', () => ({
default: {
setVapidDetails: vi.fn(),
sendNotification: mockSendNotification,
},
}))
vi.mock('@/lib/env', () => ({
env: {
NEXT_PUBLIC_VAPID_PUBLIC_KEY: 'pk',
VAPID_PRIVATE_KEY: 'sk',
VAPID_SUBJECT: 'mailto:test@example.com',
},
}))
const { mockPushSubscription } = vi.hoisted(() => ({
mockPushSubscription: {
findMany: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}))
vi.mock('@/lib/prisma', () => ({
prisma: { pushSubscription: mockPushSubscription },
}))
import { sendPushToUser } from '@/lib/push-server'
const SUB = { id: 'sub-1', endpoint: 'https://push.example.com/1', p256dh: 'p256dh', auth: 'auth' }
const PAYLOAD = { title: 'Test', body: 'Body', url: '/test' }
beforeEach(() => {
vi.clearAllMocks()
mockPushSubscription.findMany.mockResolvedValue([SUB])
mockPushSubscription.update.mockResolvedValue(SUB)
mockPushSubscription.delete.mockResolvedValue(SUB)
})
describe('sendPushToUser', () => {
it('sends notification and updates last_used_at on success', async () => {
mockSendNotification.mockResolvedValue({ statusCode: 201 })
await sendPushToUser('user-1', PAYLOAD)
expect(mockSendNotification).toHaveBeenCalledOnce()
expect(mockPushSubscription.update).toHaveBeenCalledWith({
where: { id: SUB.id },
data: { last_used_at: expect.any(Date) },
})
})
it('deletes subscription on 410 (expired)', async () => {
mockSendNotification.mockRejectedValue({ statusCode: 410 })
await sendPushToUser('user-1', PAYLOAD)
expect(mockPushSubscription.delete).toHaveBeenCalledWith({ where: { id: SUB.id } })
expect(mockPushSubscription.update).not.toHaveBeenCalled()
})
it('deletes subscription on 404 (not found)', async () => {
mockSendNotification.mockRejectedValue({ statusCode: 404 })
await sendPushToUser('user-1', PAYLOAD)
expect(mockPushSubscription.delete).toHaveBeenCalledWith({ where: { id: SUB.id } })
})
it('logs error but does not delete on other error status', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockSendNotification.mockRejectedValue({ statusCode: 500 })
await sendPushToUser('user-1', PAYLOAD)
expect(mockPushSubscription.delete).not.toHaveBeenCalled()
expect(consoleSpy).toHaveBeenCalled()
consoleSpy.mockRestore()
})
})

64
lib/push-server.ts Normal file
View file

@ -0,0 +1,64 @@
import 'server-only'
import webpush from 'web-push'
import { prisma } from '@/lib/prisma'
import { env } from '@/lib/env'
export type PushPayload = {
title: string
body: string
url: string
tag?: string
}
const vapidReady =
!!env.NEXT_PUBLIC_VAPID_PUBLIC_KEY &&
!!env.VAPID_PRIVATE_KEY &&
!!env.VAPID_SUBJECT
if (vapidReady) {
webpush.setVapidDetails(
env.VAPID_SUBJECT!,
env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
env.VAPID_PRIVATE_KEY!,
)
}
export const enabled = vapidReady
export async function sendPushToUser(userId: string, payload: PushPayload): Promise<void> {
if (!enabled) {
console.warn('[push-server] VAPID not configured — skipping push for user', userId)
return
}
const subs = await prisma.pushSubscription.findMany({ where: { user_id: userId } })
await Promise.allSettled(subs.map((sub) => sendOne(sub, payload)))
}
async function sendOne(
sub: { id: string; endpoint: string; p256dh: string; auth: string },
payload: PushPayload,
): Promise<void> {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify(payload),
)
await prisma.pushSubscription.update({
where: { id: sub.id },
data: { last_used_at: new Date() },
})
} catch (err: unknown) {
const status = (err as { statusCode?: number }).statusCode
if (status === 404 || status === 410) {
try {
await prisma.pushSubscription.delete({ where: { id: sub.id } })
} catch {
// already deleted by a concurrent request — ignore
}
} else {
console.error('[push-server] sendNotification error for endpoint', sub.endpoint, err)
}
}
}