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)
This commit is contained in:
@@ -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 { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||||
import type * as schema from '../schema'
|
import type * as schema from '../schema'
|
||||||
import { projects } from '../schema'
|
import { projects } from '../schema'
|
||||||
@@ -34,6 +34,16 @@ export async function createProject(db: Db, rawName: string): Promise<Project> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A project's name by id — used to label the push notification on file (T11). */
|
||||||
|
export async function getProjectName(db: Db, id: string): Promise<string | undefined> {
|
||||||
|
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<Project | undefined> {
|
async function findByName(db: Db, name: string): Promise<Project | undefined> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
87
server/utils/sendPush.ts
Normal file
87
server/utils/sendPush.ts
Normal file
@@ -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<typeof schema>
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
192
tests/push-send.spec.ts
Normal file
192
tests/push-send.spec.ts
Normal file
@@ -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<void> }
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user