Web Push: goal, kickoff and full-time alerts for a followed team

Fully progressive: without VAPID keys in the environment the API says
404, the bell never renders, and the service worker addition (a push
handler via workbox importScripts — no SW strategy change) is inert.
With keys: a bell on each team page requests permission, subscribes
(one team per device) and the server diffs fixture states on every
broadcast to push Kickoff / Goal / Full time to that team's followers.
Dead endpoints (404/410) self-clean. Round-trip verified locally with
dev VAPID keys; disabled mode verified too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 08:21:55 +02:00
parent ce83d4dbc4
commit 03b0fa734e
13 changed files with 331 additions and 6 deletions
+3 -2
View File
@@ -10,6 +10,7 @@
"@fastify/static": "^9.1.3",
"@fastify/websocket": "^11.2.0",
"fastify": "^5.8.5",
"zod": "^3.25.76"
"zod": "^3.25.76",
"web-push": "^3.6.7"
}
}
}
+32
View File
@@ -93,6 +93,13 @@ CREATE TABLE IF NOT EXISTS prediction_snapshots (
market_json TEXT
);
CREATE TABLE IF NOT EXISTS push_subs (
endpoint TEXT PRIMARY KEY,
json TEXT NOT NULL,
team TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS inplay_history (
fixture_num INTEGER NOT NULL,
minute INTEGER NOT NULL,
@@ -147,6 +154,31 @@ export function cacheSet(key: string, value: string, ttl: number): void {
.run(key, value, Date.now(), ttl);
}
// ---- Web Push subscriptions (goal/kickoff notifications, opt-in per team) ----
export interface PushSub {
endpoint: string;
json: string;
team: string | null;
}
export function addPushSub(endpoint: string, json: string, team: string | null): void {
db()
.prepare('INSERT OR REPLACE INTO push_subs (endpoint, json, team, created_at) VALUES (?, ?, ?, ?)')
.run(endpoint, json, team, Date.now());
}
export function removePushSub(endpoint: string): void {
db().prepare('DELETE FROM push_subs WHERE endpoint = ?').run(endpoint);
}
export function pushSubsForTeams(teams: string[]): PushSub[] {
if (teams.length === 0) return [];
const marks = teams.map(() => '?').join(',');
return db()
.prepare(`SELECT endpoint, json, team FROM push_subs WHERE team IN (${marks})`)
.all(...teams) as unknown as PushSub[];
}
// ---- in-play win-probability history (the momentum curve on match pages) ----
export interface InplayPoint {
minute: number;
+22
View File
@@ -12,6 +12,7 @@ import { buildPreview, buildTeamProfile } from './preview';
import { buildScoreboard, snapshotCheck } from './scoreboard';
import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchExt } from './db/db';
import { inPlayProbs } from '../../src/lib/model/inplay';
import { PushNotifier, pushEnabled, subscribe as pushSubscribe, unsubscribe as pushUnsubscribe, vapidPublicKey } from './push';
import type { EspnSummary } from './ingest/espnNormalize';
import { loadStatic } from './preview';
import { canonicalTeam } from '../../src/lib/teams';
@@ -53,9 +54,11 @@ export function buildServer() {
};
/** Re-push the snapshot, and predictions too if the finished-result set moved. */
const notifier = new PushNotifier();
const broadcast = (): void => {
sendAll(snapshotMessage());
if (model.recompute(state.allFixtures())) sendAll(predictionsMessage());
try { notifier.tick(state.allFixtures()); } catch { /* never block a broadcast */ }
};
// Record the in-play win probability per game state while matches run —
@@ -88,6 +91,25 @@ export function buildServer() {
return { points: Number.isFinite(num) ? inplayHistory(num) : [] };
});
// Web Push (feature-flagged on VAPID env): key discovery + subscriptions.
app.get('/api/push/key', async (_req, reply) =>
pushEnabled ? { key: vapidPublicKey } : reply.code(404).send({ error: 'push disabled' }),
);
app.post('/api/push/subscribe', async (req, reply) => {
if (!pushEnabled) return reply.code(404).send({ error: 'push disabled' });
const b = (req.body ?? {}) as { subscription?: { endpoint?: string }; team?: string };
if (!b.subscription?.endpoint || !b.team) return reply.code(400).send({ error: 'subscription + team required' });
pushSubscribe(b.subscription as { endpoint: string }, b.team);
return { ok: true };
});
app.post('/api/push/unsubscribe', async (req, reply) => {
if (!pushEnabled) return reply.code(404).send({ error: 'push disabled' });
const b = (req.body ?? {}) as { endpoint?: string };
if (!b.endpoint) return reply.code(400).send({ error: 'endpoint required' });
pushUnsubscribe(b.endpoint);
return { ok: true };
});
// Golden Boot: per-player goals aggregated from structured ESPN scoring
// plays across every started match (own goals + shootout kicks excluded).
app.get('/api/goldenboot', async () => {
+84
View File
@@ -0,0 +1,84 @@
// Web Push for goal/kickoff/full-time alerts on a followed team. Entirely
// opt-in and feature-flagged: without VAPID keys in the environment the API
// reports disabled, the client hides its UI, and nothing else changes.
import webpush from 'web-push';
import { addPushSub, pushSubsForTeams, removePushSub } from './db/db';
import type { Fixture } from '../../src/lib/types';
const PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY?.trim() ?? '';
const PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY?.trim() ?? '';
const SUBJECT = process.env.VAPID_SUBJECT?.trim() || 'mailto:nilsbriggen@gmail.com';
export const pushEnabled = Boolean(PUBLIC_KEY && PRIVATE_KEY);
export const vapidPublicKey = PUBLIC_KEY;
if (pushEnabled) {
webpush.setVapidDetails(SUBJECT, PUBLIC_KEY, PRIVATE_KEY);
console.log('[push] web push enabled');
}
export function subscribe(subscription: { endpoint: string }, team: string): void {
addPushSub(subscription.endpoint, JSON.stringify(subscription), team);
}
export function unsubscribe(endpoint: string): void {
removePushSub(endpoint);
}
async function send(team: string[], title: string, body: string, url: string): Promise<void> {
if (!pushEnabled) return;
const subs = pushSubsForTeams(team);
await Promise.allSettled(
subs.map(async (s) => {
try {
await webpush.sendNotification(JSON.parse(s.json), JSON.stringify({ title, body, url }));
} catch (e) {
const code = (e as { statusCode?: number }).statusCode;
if (code === 404 || code === 410) removePushSub(s.endpoint); // gone — clean up
}
}),
);
}
interface ScoreState { home: number | null; away: number | null; status: string }
/** Diff fixture states between broadcasts and push kickoff/goal/full-time
* alerts to followers of either team. State lives in-memory: after a restart
* the first broadcast just primes the cache (no notification replays). */
export class PushNotifier {
private last = new Map<number, ScoreState>();
private primed = false;
tick(fixtures: Fixture[]): void {
if (!pushEnabled) return;
const interesting = fixtures.filter((f) => f.status !== 'scheduled' && f.home.team && f.away.team);
if (!this.primed) {
for (const f of interesting) this.last.set(f.num, { home: f.homeScore, away: f.awayScore, status: f.status });
this.primed = true;
return;
}
for (const f of interesting) {
const prev = this.last.get(f.num);
const cur: ScoreState = { home: f.homeScore, away: f.awayScore, status: f.status };
this.last.set(f.num, cur);
const home = f.home.team!;
const away = f.away.team!;
const teams = [home, away];
const url = `/match/${f.num}`;
const score = `${cur.home ?? 0}${cur.away ?? 0}`;
if (!prev) {
if (f.status === 'live') void send(teams, `Kickoff: ${home} vs ${away}`, 'The match is underway.', url);
continue;
}
if (prev.status !== 'live' && f.status === 'live') {
void send(teams, `Kickoff: ${home} vs ${away}`, 'The match is underway.', url);
}
if ((cur.home ?? 0) + (cur.away ?? 0) > (prev.home ?? 0) + (prev.away ?? 0)) {
void send(teams, `Goal! ${home} ${score} ${away}`, f.minute ? `${f.minute}'` : '', url);
}
if (prev.status === 'live' && f.status === 'finished') {
void send(teams, `Full time: ${home} ${score} ${away}`, '', url);
}
}
}
}