From b56577ddc2fe96cc4af6d8da0ad26cdf40156e59 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Thu, 28 May 2026 01:51:36 +0200 Subject: [PATCH] feat(push): fan out web push to subscribed devices on defect filed Prune endpoints the push service rejects with 404/410; log and skip transient failures so one bad device never blocks the rest. (T11/F7) --- server/db/repositories/projects.ts | 12 +- server/utils/sendPush.ts | 87 +++++++++++++ tests/push-send.spec.ts | 192 +++++++++++++++++++++++++++++ 3 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 server/utils/sendPush.ts create mode 100644 tests/push-send.spec.ts diff --git a/server/db/repositories/projects.ts b/server/db/repositories/projects.ts index 401d4b3..c021878 100644 --- a/server/db/repositories/projects.ts +++ b/server/db/repositories/projects.ts @@ -1,4 +1,4 @@ -import { asc, sql } from 'drizzle-orm' +import { asc, eq, sql } from 'drizzle-orm' import type { NodePgDatabase } from 'drizzle-orm/node-postgres' import type * as schema from '../schema' import { projects } from '../schema' @@ -34,6 +34,16 @@ export async function createProject(db: Db, rawName: string): Promise { } } +/** A project's name by id — used to label the push notification on file (T11). */ +export async function getProjectName(db: Db, id: string): Promise { + const [row] = await db + .select({ name: projects.name }) + .from(projects) + .where(eq(projects.id, id)) + .limit(1) + return row?.name +} + async function findByName(db: Db, name: string): Promise { const [row] = await db .select() diff --git a/server/utils/sendPush.ts b/server/utils/sendPush.ts new file mode 100644 index 0000000..ab16ead --- /dev/null +++ b/server/utils/sendPush.ts @@ -0,0 +1,87 @@ +import webpush from 'web-push' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import type * as schema from '../db/schema' +import { + listSubscriptions, + deleteSubscriptionByEndpoint, +} from '../db/repositories/pushSubscriptions' +import { getProjectName } from '../db/repositories/projects' +import { sections } from '../../app/board/definition' + +type Db = NodePgDatabase + +const sectionLabels = new Map(sections.map((s) => [s.id, s.label])) + +const SNIPPET_LIMIT = 140 + +export interface VapidConfig { + subject: string + publicKey: string + privateKey: string +} + +export interface FiledDefect { + sectionId: string + projectId: string + verbatim: string +} + +/** The notification body the service worker (T10) expects: `{ title, body, tag, url }`. */ +export function buildPayload(input: { sectionLabel: string; projectName: string; verbatim: string }) { + const snippet = + input.verbatim.length > SNIPPET_LIMIT + ? input.verbatim.slice(0, SNIPPET_LIMIT - 1).trimEnd() + '…' + : input.verbatim + return { + title: `New defect — ${input.sectionLabel}`, + body: `${input.projectName}: ${snippet}`, + tag: 'defect-filed', + url: '/defects', + } +} + +/** + * Fan a Web Push out to every enrolled device after a defect is filed (F7). + * Called fire-and-forget from the file-defect handler (via `event.waitUntil`) + * so filing latency is unaffected. Never throws: a stale endpoint (404/410) is + * pruned, any other failure is logged and the remaining devices still get sent. + */ +export async function sendPushOnDefectFiled( + db: Db, + vapid: VapidConfig, + defect: FiledDefect, +): Promise { + // Without keys there is nothing to sign with (dev / preview); stay silent. + if (!vapid.publicKey || !vapid.privateKey) return + + const subscriptions = await listSubscriptions(db) + if (subscriptions.length === 0) return + + const projectName = (await getProjectName(db, defect.projectId)) ?? 'Unknown project' + const sectionLabel = sectionLabels.get(defect.sectionId) ?? defect.sectionId + const payload = JSON.stringify(buildPayload({ sectionLabel, projectName, verbatim: defect.verbatim })) + + webpush.setVapidDetails(vapid.subject, vapid.publicKey, vapid.privateKey) + + await Promise.all( + subscriptions.map((sub) => + webpush + .sendNotification( + { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, + payload, + ) + .catch(async (err: unknown) => { + if ( + err instanceof webpush.WebPushError && + (err.statusCode === 404 || err.statusCode === 410) + ) { + // The device is gone — drop it so we stop sending (the 410 path). + await deleteSubscriptionByEndpoint(db, sub.endpoint) + } else { + // Transient or unexpected: keep the device, log, and move on (no retry queue in v1). + console.error('[push] send failed for', sub.endpoint, err) + } + }), + ), + ) +} diff --git a/tests/push-send.spec.ts b/tests/push-send.spec.ts new file mode 100644 index 0000000..4250422 --- /dev/null +++ b/tests/push-send.spec.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest' +import webpush from 'web-push' +import { setupTestDb, truncateAll, type TestDb } from './helpers/db' +import { createProject } from '../server/db/repositories/projects' +import { saveSubscription, listSubscriptions } from '../server/db/repositories/pushSubscriptions' +import { buildPayload, sendPushOnDefectFiled } from '../server/utils/sendPush' + +// web-push is mocked so the send never leaves the process: we assert on the +// payload it would deliver and simulate the push service's 410/404 responses. +const { sendNotification, setVapidDetails } = vi.hoisted(() => ({ + sendNotification: vi.fn(), + setVapidDetails: vi.fn(), +})) +vi.mock('web-push', () => { + class WebPushError extends Error { + statusCode: number + constructor(message: string, statusCode: number) { + super(message) + this.statusCode = statusCode + } + } + return { default: { sendNotification, setVapidDetails, WebPushError } } +}) + +const vapid = { subject: 'mailto:owner@theodo.com', publicKey: 'pub-key', privateKey: 'priv-key' } + +describe('buildPayload', () => { + it('renders the section label, project and verbatim into the SW payload shape', () => { + const payload = buildPayload({ + sectionLabel: 'Macroplan', + projectName: 'Acme', + verbatim: 'builds are flaky', + }) + expect(payload).toMatchObject({ + title: expect.stringContaining('Macroplan'), + body: expect.stringContaining('Acme'), + url: '/defects', + }) + expect(payload.body).toContain('builds are flaky') + }) + + it('truncates a long verbatim to a snippet', () => { + const payload = buildPayload({ + sectionLabel: 'Macroplan', + projectName: 'Acme', + verbatim: 'x'.repeat(300), + }) + expect(payload.body.length).toBeLessThan(200) + expect(payload.body).toContain('…') + }) +}) + +describe('sendPushOnDefectFiled', () => { + let db: TestDb + let pool: { end: () => Promise } + + beforeAll(async () => { + const setup = await setupTestDb() + db = setup.db + pool = setup.pool + }) + + afterAll(async () => { + await pool.end() + }) + + beforeEach(async () => { + await truncateAll(db) + sendNotification.mockReset().mockResolvedValue(undefined) + setVapidDetails.mockReset() + }) + + async function seedTwoDevices() { + await saveSubscription(db, { + endpoint: 'https://push.example/device-a', + p256dh: 'p256dh-a', + auth: 'auth-a', + reporterEmail: 'owner@theodo.com', + }) + await saveSubscription(db, { + endpoint: 'https://push.example/device-b', + p256dh: 'p256dh-b', + auth: 'auth-b', + reporterEmail: 'owner@theodo.com', + }) + } + + it('sets VAPID details and pushes the defect to every subscribed device', async () => { + const project = await createProject(db, 'Acme') + await seedTwoDevices() + + await sendPushOnDefectFiled(db, vapid, { + sectionId: 'macroplan', + projectId: project.id, + verbatim: 'login is flaky', + }) + + expect(setVapidDetails).toHaveBeenCalledWith(vapid.subject, vapid.publicKey, vapid.privateKey) + expect(sendNotification).toHaveBeenCalledTimes(2) + + const [sub, payload] = sendNotification.mock.calls[0]! + expect(sub).toMatchObject({ endpoint: expect.stringContaining('device-'), keys: expect.any(Object) }) + const decoded = JSON.parse(payload as string) + expect(decoded.title).toContain('Macroplan') + expect(decoded.body).toContain('Acme') + expect(decoded.body).toContain('login is flaky') + }) + + it('prunes a subscription when the push service returns 410 Gone', async () => { + const project = await createProject(db, 'Acme') + await seedTwoDevices() + sendNotification.mockImplementation((sub: webpush.PushSubscription) => { + if (sub.endpoint.endsWith('device-a')) { + return Promise.reject(new webpush.WebPushError('gone', 410)) + } + return Promise.resolve(undefined) + }) + + await sendPushOnDefectFiled(db, vapid, { + sectionId: 'macroplan', + projectId: project.id, + verbatim: 'x', + }) + + const remaining = (await listSubscriptions(db)).map((s) => s.endpoint) + expect(remaining).toEqual(['https://push.example/device-b']) + }) + + it('prunes a subscription when the push service returns 404 Not Found', async () => { + const project = await createProject(db, 'Acme') + await seedTwoDevices() + sendNotification.mockImplementation((sub: webpush.PushSubscription) => + sub.endpoint.endsWith('device-a') + ? Promise.reject(new webpush.WebPushError('not found', 404)) + : Promise.resolve(undefined), + ) + + await sendPushOnDefectFiled(db, vapid, { + sectionId: 'macroplan', + projectId: project.id, + verbatim: 'x', + }) + + const remaining = (await listSubscriptions(db)).map((s) => s.endpoint) + expect(remaining).toEqual(['https://push.example/device-b']) + }) + + it('keeps the subscription and does not throw on a transient send error', async () => { + const project = await createProject(db, 'Acme') + await seedTwoDevices() + sendNotification.mockImplementation((sub: webpush.PushSubscription) => + sub.endpoint.endsWith('device-a') + ? Promise.reject(new webpush.WebPushError('server error', 500)) + : Promise.resolve(undefined), + ) + + await expect( + sendPushOnDefectFiled(db, vapid, { + sectionId: 'macroplan', + projectId: project.id, + verbatim: 'x', + }), + ).resolves.toBeUndefined() + + // A 500 is transient — both devices are kept for the next defect. + expect((await listSubscriptions(db)).length).toBe(2) + }) + + it('does nothing when there are no subscriptions', async () => { + const project = await createProject(db, 'Acme') + await sendPushOnDefectFiled(db, vapid, { + sectionId: 'macroplan', + projectId: project.id, + verbatim: 'x', + }) + expect(sendNotification).not.toHaveBeenCalled() + }) + + it('is a no-op when VAPID keys are not configured', async () => { + const project = await createProject(db, 'Acme') + await seedTwoDevices() + + await sendPushOnDefectFiled( + db, + { subject: 'mailto:owner@theodo.com', publicKey: '', privateKey: '' }, + { sectionId: 'macroplan', projectId: project.id, verbatim: 'x' }, + ) + + expect(setVapidDetails).not.toHaveBeenCalled() + expect(sendNotification).not.toHaveBeenCalled() + }) +})