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:
+3
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user