From 6d78f1112a89ecfd3f96aa78a4ff27ecd990f22f Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 11 May 2026 10:21:49 -0700 Subject: [PATCH 1/3] Wire frontend push subscription to notifier service --- package.json | 4 +- services/assignment-notifier/README.md | 33 ++++ services/assignment-notifier/data/.gitkeep | 0 services/assignment-notifier/data/store.json | 5 + services/assignment-notifier/dist/config.js | 23 +++ services/assignment-notifier/dist/diff.js | 47 ++++++ services/assignment-notifier/dist/index.js | 45 ++++++ .../assignment-notifier/dist/pushSender.js | 21 +++ services/assignment-notifier/dist/store.js | 33 ++++ services/assignment-notifier/dist/types.js | 2 + .../assignment-notifier/dist/wcifClient.js | 14 ++ services/assignment-notifier/src/config.ts | 30 ++++ services/assignment-notifier/src/diff.ts | 54 +++++++ services/assignment-notifier/src/index.ts | 55 +++++++ .../assignment-notifier/src/pushSender.ts | 24 +++ services/assignment-notifier/src/store.ts | 32 ++++ services/assignment-notifier/src/types.ts | 38 +++++ .../assignment-notifier/src/wcifClient.ts | 18 +++ services/assignment-notifier/tsconfig.json | 14 ++ src/App.tsx | 9 ++ src/hooks/useAssignmentNotifications/index.ts | 1 + .../useAssignmentNotifications.ts | 53 +++++++ .../notifications/assignmentNotifications.ts | 148 ++++++++++++++++++ src/pages/Settings/index.tsx | 74 ++++++--- 24 files changed, 753 insertions(+), 24 deletions(-) create mode 100644 services/assignment-notifier/README.md create mode 100644 services/assignment-notifier/data/.gitkeep create mode 100644 services/assignment-notifier/data/store.json create mode 100644 services/assignment-notifier/dist/config.js create mode 100644 services/assignment-notifier/dist/diff.js create mode 100644 services/assignment-notifier/dist/index.js create mode 100644 services/assignment-notifier/dist/pushSender.js create mode 100644 services/assignment-notifier/dist/store.js create mode 100644 services/assignment-notifier/dist/types.js create mode 100644 services/assignment-notifier/dist/wcifClient.js create mode 100644 services/assignment-notifier/src/config.ts create mode 100644 services/assignment-notifier/src/diff.ts create mode 100644 services/assignment-notifier/src/index.ts create mode 100644 services/assignment-notifier/src/pushSender.ts create mode 100644 services/assignment-notifier/src/store.ts create mode 100644 services/assignment-notifier/src/types.ts create mode 100644 services/assignment-notifier/src/wcifClient.ts create mode 100644 services/assignment-notifier/tsconfig.json create mode 100644 src/hooks/useAssignmentNotifications/index.ts create mode 100644 src/hooks/useAssignmentNotifications/useAssignmentNotifications.ts create mode 100644 src/lib/notifications/assignmentNotifications.ts diff --git a/package.json b/package.json index fdcfd33..9e4e033 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "lint": "eslint ./src/", "check:type": "tsc --noEmit", "serve": "vite preview", - "prepare": "husky" + "prepare": "husky", + "notify:service": "yarn notify:service:build && node services/assignment-notifier/dist/index.js", + "notify:service:build": "tsc -p services/assignment-notifier/tsconfig.json" }, "dependencies": { "@apollo/client": "^3.8.7", diff --git a/services/assignment-notifier/README.md b/services/assignment-notifier/README.md new file mode 100644 index 0000000..81dae8a --- /dev/null +++ b/services/assignment-notifier/README.md @@ -0,0 +1,33 @@ +# Assignment Notifier Service (scaffold) + +This is a single-service scaffold that polls WCIFs, detects assignment changes, and dispatches placeholder push notifications. + +## What it includes + +- WCIF polling loop +- Assignment hash snapshots and diffing +- Notification job generation with dedupe keys +- Local JSON store for snapshots/subscriptions +- Placeholder push sender (logs output) + +## Configure + +Create environment variables: + +- `WCA_OAUTH_TOKEN` - WCA token with WCIF access +- `WCIF_COMPETITION_IDS` - comma-separated competition IDs +- `WCIF_API_BASE_URL` (optional, defaults to WCA v0 API) +- `WCIF_POLL_INTERVAL_MS` (optional, defaults to 300000) + +## Run + +```bash +yarn notify:service +``` + +## Next steps + +- Replace `sendPushNotifications` with actual Web Push delivery. +- Add API routes to register/remove subscriptions. +- Move store to a persistent DB. +- Add upcoming-assignment reminder jobs. diff --git a/services/assignment-notifier/data/.gitkeep b/services/assignment-notifier/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/services/assignment-notifier/data/store.json b/services/assignment-notifier/data/store.json new file mode 100644 index 0000000..0ea44ac --- /dev/null +++ b/services/assignment-notifier/data/store.json @@ -0,0 +1,5 @@ +{ + "snapshots": [], + "subscriptions": [], + "deliveredDedupeKeys": [] +} diff --git a/services/assignment-notifier/dist/config.js b/services/assignment-notifier/dist/config.js new file mode 100644 index 0000000..01e282d --- /dev/null +++ b/services/assignment-notifier/dist/config.js @@ -0,0 +1,23 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { value: true }); +exports.getNotifierServiceConfig = getNotifierServiceConfig; +function readNumber(name, fallback) { + const raw = process.env[name]; + if (!raw) { + return fallback; + } + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : fallback; +} +function getNotifierServiceConfig() { + const competitionIds = (process.env.WCIF_COMPETITION_IDS ?? '') + .split(',') + .map((part) => part.trim()) + .filter(Boolean); + return { + wcifPollIntervalMs: readNumber('WCIF_POLL_INTERVAL_MS', 5 * 60 * 1000), + wcifApiBaseUrl: process.env.WCIF_API_BASE_URL ?? 'https://www.worldcubeassociation.org/api/v0', + apiToken: process.env.WCA_OAUTH_TOKEN ?? '', + competitionIds, + }; +} diff --git a/services/assignment-notifier/dist/diff.js b/services/assignment-notifier/dist/diff.js new file mode 100644 index 0000000..322a54d --- /dev/null +++ b/services/assignment-notifier/dist/diff.js @@ -0,0 +1,47 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { value: true }); +exports.createAssignmentSnapshots = createAssignmentSnapshots; +exports.buildNotificationJobs = buildNotificationJobs; +const crypto_1 = require('crypto'); +function hashAssignments(value) { + return (0, crypto_1.createHash)('sha256') + .update(JSON.stringify(value ?? null)) + .digest('hex'); +} +function createAssignmentSnapshots(wcif) { + return wcif.persons + .filter((person) => person.wcaUserId) + .map((person) => ({ + competitionId: wcif.id, + personWcaUserId: person.wcaUserId, + assignmentsHash: hashAssignments(person.assignments), + fetchedAt: new Date().toISOString(), + })); +} +function buildNotificationJobs(params) { + const previousByUser = new Map( + params.previousSnapshots.map((snapshot) => [ + `${snapshot.competitionId}:${snapshot.personWcaUserId}`, + snapshot, + ]), + ); + return params.nextSnapshots.flatMap((snapshot) => { + const id = `${snapshot.competitionId}:${snapshot.personWcaUserId}`; + const previous = previousByUser.get(id); + if (!previous || previous.assignmentsHash === snapshot.assignmentsHash) { + return []; + } + const dedupeKey = `assignment-update:${id}:${snapshot.assignmentsHash}`; + return [ + { + id: `${Date.now()}:${id}`, + userId: snapshot.personWcaUserId, + competitionId: snapshot.competitionId, + title: 'New assignment update', + body: 'Your assignments were updated. Open the app to review the latest groups.', + dedupeKey, + createdAt: new Date().toISOString(), + }, + ]; + }); +} diff --git a/services/assignment-notifier/dist/index.js b/services/assignment-notifier/dist/index.js new file mode 100644 index 0000000..6e19863 --- /dev/null +++ b/services/assignment-notifier/dist/index.js @@ -0,0 +1,45 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { value: true }); +const diff_1 = require('./diff'); +const config_1 = require('./config'); +const pushSender_1 = require('./pushSender'); +const store_1 = require('./store'); +const wcifClient_1 = require('./wcifClient'); +async function runOnce() { + const config = (0, config_1.getNotifierServiceConfig)(); + const store = await (0, store_1.readStore)(); + for (const competitionId of config.competitionIds) { + const wcif = await (0, wcifClient_1.fetchWcif)(competitionId, { + apiBaseUrl: config.wcifApiBaseUrl, + token: config.apiToken, + }); + const nextSnapshots = (0, diff_1.createAssignmentSnapshots)(wcif); + const previousSnapshots = store.snapshots.filter( + (snapshot) => snapshot.competitionId === competitionId, + ); + const jobs = (0, diff_1.buildNotificationJobs)({ previousSnapshots, nextSnapshots }).filter( + (job) => !store.deliveredDedupeKeys.includes(job.dedupeKey), + ); + await (0, pushSender_1.sendPushNotifications)({ + jobs, + subscriptions: store.subscriptions, + }); + store.snapshots = [ + ...store.snapshots.filter((snapshot) => snapshot.competitionId !== competitionId), + ...nextSnapshots, + ]; + store.deliveredDedupeKeys.push(...jobs.map((job) => job.dedupeKey)); + } + await (0, store_1.saveStore)(store); +} +async function main() { + const config = (0, config_1.getNotifierServiceConfig)(); + await runOnce(); + if (process.env.NOTIFIER_RUN_ONCE === 'true') { + return; + } + setInterval(() => { + void runOnce(); + }, config.wcifPollIntervalMs); +} +void main(); diff --git a/services/assignment-notifier/dist/pushSender.js b/services/assignment-notifier/dist/pushSender.js new file mode 100644 index 0000000..d24a062 --- /dev/null +++ b/services/assignment-notifier/dist/pushSender.js @@ -0,0 +1,21 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { value: true }); +exports.sendPushNotifications = sendPushNotifications; +async function sendPushNotifications(params) { + for (const job of params.jobs) { + const subscriptions = params.subscriptions.filter( + (subscription) => subscription.userId === job.userId, + ); + for (const subscription of subscriptions) { + console.log( + '[push-notifier] send placeholder push', + JSON.stringify({ + userId: subscription.userId, + endpoint: subscription.endpoint, + competitionId: job.competitionId, + dedupeKey: job.dedupeKey, + }), + ); + } + } +} diff --git a/services/assignment-notifier/dist/store.js b/services/assignment-notifier/dist/store.js new file mode 100644 index 0000000..01a7da2 --- /dev/null +++ b/services/assignment-notifier/dist/store.js @@ -0,0 +1,33 @@ +'use strict'; +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, '__esModule', { value: true }); +exports.readStore = readStore; +exports.saveStore = saveStore; +const promises_1 = require('fs/promises'); +const path_1 = __importDefault(require('path')); +const STORE_PATH = path_1.default.resolve( + process.cwd(), + 'services/assignment-notifier/data/store.json', +); +async function ensureStore() { + try { + const content = await (0, promises_1.readFile)(STORE_PATH, 'utf8'); + return JSON.parse(content); + } catch { + return { + snapshots: [], + subscriptions: [], + deliveredDedupeKeys: [], + }; + } +} +async function readStore() { + return ensureStore(); +} +async function saveStore(store) { + await (0, promises_1.writeFile)(STORE_PATH, JSON.stringify(store, null, 2), 'utf8'); +} diff --git a/services/assignment-notifier/dist/types.js b/services/assignment-notifier/dist/types.js new file mode 100644 index 0000000..db8b17d --- /dev/null +++ b/services/assignment-notifier/dist/types.js @@ -0,0 +1,2 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { value: true }); diff --git a/services/assignment-notifier/dist/wcifClient.js b/services/assignment-notifier/dist/wcifClient.js new file mode 100644 index 0000000..adecf64 --- /dev/null +++ b/services/assignment-notifier/dist/wcifClient.js @@ -0,0 +1,14 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { value: true }); +exports.fetchWcif = fetchWcif; +async function fetchWcif(competitionId, options) { + const response = await fetch(`${options.apiBaseUrl}/competitions/${competitionId}/wcif`, { + headers: { + Authorization: `Bearer ${options.token}`, + }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch WCIF for ${competitionId}: ${response.status}`); + } + return await response.json(); +} diff --git a/services/assignment-notifier/src/config.ts b/services/assignment-notifier/src/config.ts new file mode 100644 index 0000000..db1460a --- /dev/null +++ b/services/assignment-notifier/src/config.ts @@ -0,0 +1,30 @@ +export interface NotifierServiceConfig { + wcifPollIntervalMs: number; + wcifApiBaseUrl: string; + apiToken: string; + competitionIds: string[]; +} + +function readNumber(name: string, fallback: number) { + const raw = process.env[name]; + if (!raw) { + return fallback; + } + + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : fallback; +} + +export function getNotifierServiceConfig(): NotifierServiceConfig { + const competitionIds = (process.env.WCIF_COMPETITION_IDS ?? '') + .split(',') + .map((part) => part.trim()) + .filter(Boolean); + + return { + wcifPollIntervalMs: readNumber('WCIF_POLL_INTERVAL_MS', 5 * 60 * 1000), + wcifApiBaseUrl: process.env.WCIF_API_BASE_URL ?? 'https://www.worldcubeassociation.org/api/v0', + apiToken: process.env.WCA_OAUTH_TOKEN ?? '', + competitionIds, + }; +} diff --git a/services/assignment-notifier/src/diff.ts b/services/assignment-notifier/src/diff.ts new file mode 100644 index 0000000..2aedf4c --- /dev/null +++ b/services/assignment-notifier/src/diff.ts @@ -0,0 +1,54 @@ +import { createHash } from 'crypto'; +import type { AssignmentSnapshot, NotificationJob, WcifPayload } from './types'; + +function hashAssignments(value: unknown) { + return createHash('sha256') + .update(JSON.stringify(value ?? null)) + .digest('hex'); +} + +export function createAssignmentSnapshots(wcif: WcifPayload): AssignmentSnapshot[] { + return wcif.persons + .filter((person) => person.wcaUserId) + .map((person) => ({ + competitionId: wcif.id, + personWcaUserId: person.wcaUserId, + assignmentsHash: hashAssignments(person.assignments), + fetchedAt: new Date().toISOString(), + })); +} + +export function buildNotificationJobs(params: { + previousSnapshots: AssignmentSnapshot[]; + nextSnapshots: AssignmentSnapshot[]; +}): NotificationJob[] { + const previousByUser = new Map( + params.previousSnapshots.map((snapshot) => [ + `${snapshot.competitionId}:${snapshot.personWcaUserId}`, + snapshot, + ]), + ); + + return params.nextSnapshots.flatMap((snapshot) => { + const id = `${snapshot.competitionId}:${snapshot.personWcaUserId}`; + const previous = previousByUser.get(id); + + if (!previous || previous.assignmentsHash === snapshot.assignmentsHash) { + return []; + } + + const dedupeKey = `assignment-update:${id}:${snapshot.assignmentsHash}`; + + return [ + { + id: `${Date.now()}:${id}`, + userId: snapshot.personWcaUserId, + competitionId: snapshot.competitionId, + title: 'New assignment update', + body: 'Your assignments were updated. Open the app to review the latest groups.', + dedupeKey, + createdAt: new Date().toISOString(), + }, + ]; + }); +} diff --git a/services/assignment-notifier/src/index.ts b/services/assignment-notifier/src/index.ts new file mode 100644 index 0000000..28140b7 --- /dev/null +++ b/services/assignment-notifier/src/index.ts @@ -0,0 +1,55 @@ +import { getNotifierServiceConfig } from './config'; +import { buildNotificationJobs, createAssignmentSnapshots } from './diff'; +import { sendPushNotifications } from './pushSender'; +import { readStore, saveStore } from './store'; +import { fetchWcif } from './wcifClient'; + +async function runOnce() { + const config = getNotifierServiceConfig(); + const store = await readStore(); + + for (const competitionId of config.competitionIds) { + const wcif = await fetchWcif(competitionId, { + apiBaseUrl: config.wcifApiBaseUrl, + token: config.apiToken, + }); + + const nextSnapshots = createAssignmentSnapshots(wcif); + const previousSnapshots = store.snapshots.filter( + (snapshot) => snapshot.competitionId === competitionId, + ); + + const jobs = buildNotificationJobs({ previousSnapshots, nextSnapshots }).filter( + (job) => !store.deliveredDedupeKeys.includes(job.dedupeKey), + ); + + await sendPushNotifications({ + jobs, + subscriptions: store.subscriptions, + }); + + store.snapshots = [ + ...store.snapshots.filter((snapshot) => snapshot.competitionId !== competitionId), + ...nextSnapshots, + ]; + store.deliveredDedupeKeys.push(...jobs.map((job) => job.dedupeKey)); + } + + await saveStore(store); +} + +async function main() { + const config = getNotifierServiceConfig(); + + await runOnce(); + + if (process.env.NOTIFIER_RUN_ONCE === 'true') { + return; + } + + setInterval(() => { + void runOnce(); + }, config.wcifPollIntervalMs); +} + +void main(); diff --git a/services/assignment-notifier/src/pushSender.ts b/services/assignment-notifier/src/pushSender.ts new file mode 100644 index 0000000..a9c01a6 --- /dev/null +++ b/services/assignment-notifier/src/pushSender.ts @@ -0,0 +1,24 @@ +import type { NotificationJob, PushSubscriptionRecord } from './types'; + +export async function sendPushNotifications(params: { + jobs: NotificationJob[]; + subscriptions: PushSubscriptionRecord[]; +}) { + for (const job of params.jobs) { + const subscriptions = params.subscriptions.filter( + (subscription) => subscription.userId === job.userId, + ); + + for (const subscription of subscriptions) { + console.log( + '[push-notifier] send placeholder push', + JSON.stringify({ + userId: subscription.userId, + endpoint: subscription.endpoint, + competitionId: job.competitionId, + dedupeKey: job.dedupeKey, + }), + ); + } + } +} diff --git a/services/assignment-notifier/src/store.ts b/services/assignment-notifier/src/store.ts new file mode 100644 index 0000000..f2adb94 --- /dev/null +++ b/services/assignment-notifier/src/store.ts @@ -0,0 +1,32 @@ +import { readFile, writeFile } from 'fs/promises'; +import path from 'path'; +import type { AssignmentSnapshot, PushSubscriptionRecord } from './types'; + +interface ServiceStore { + snapshots: AssignmentSnapshot[]; + subscriptions: PushSubscriptionRecord[]; + deliveredDedupeKeys: string[]; +} + +const STORE_PATH = path.resolve(process.cwd(), 'services/assignment-notifier/data/store.json'); + +async function ensureStore(): Promise { + try { + const content = await readFile(STORE_PATH, 'utf8'); + return JSON.parse(content) as ServiceStore; + } catch { + return { + snapshots: [], + subscriptions: [], + deliveredDedupeKeys: [], + }; + } +} + +export async function readStore() { + return ensureStore(); +} + +export async function saveStore(store: ServiceStore) { + await writeFile(STORE_PATH, JSON.stringify(store, null, 2), 'utf8'); +} diff --git a/services/assignment-notifier/src/types.ts b/services/assignment-notifier/src/types.ts new file mode 100644 index 0000000..fd45311 --- /dev/null +++ b/services/assignment-notifier/src/types.ts @@ -0,0 +1,38 @@ +export interface PushSubscriptionRecord { + id: string; + userId: number; + endpoint: string; + p256dh: string; + auth: string; + createdAt: string; + updatedAt: string; +} + +export interface AssignmentSnapshot { + competitionId: string; + personWcaUserId: number; + assignmentsHash: string; + fetchedAt: string; +} + +export interface NotificationJob { + id: string; + userId: number; + competitionId: string; + title: string; + body: string; + dedupeKey: string; + createdAt: string; +} + +export interface WcifPerson { + wcaUserId: number; + name: string; + assignments?: unknown; +} + +export interface WcifPayload { + id: string; + name: string; + persons: WcifPerson[]; +} diff --git a/services/assignment-notifier/src/wcifClient.ts b/services/assignment-notifier/src/wcifClient.ts new file mode 100644 index 0000000..a621f7d --- /dev/null +++ b/services/assignment-notifier/src/wcifClient.ts @@ -0,0 +1,18 @@ +import type { WcifPayload } from './types'; + +export async function fetchWcif( + competitionId: string, + options: { apiBaseUrl: string; token: string }, +): Promise { + const response = await fetch(`${options.apiBaseUrl}/competitions/${competitionId}/wcif`, { + headers: { + Authorization: `Bearer ${options.token}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch WCIF for ${competitionId}: ${response.status}`); + } + + return (await response.json()) as WcifPayload; +} diff --git a/services/assignment-notifier/tsconfig.json b/services/assignment-notifier/tsconfig.json new file mode 100644 index 0000000..8b63243 --- /dev/null +++ b/services/assignment-notifier/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/src/App.tsx b/src/App.tsx index 882b13e..245bd5c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import client from './apolloClient'; import { usePageTracking } from './hooks/usePageTracking'; import { CompetitionLayout } from './layouts/CompetitionLayout'; import { RootLayout } from './layouts/RootLayout'; +import { bootstrapAssignmentNotificationChecks } from './lib/notifications/assignmentNotifications'; import About from './pages/About'; import CompetitionEvents from './pages/Competition/ByGroup/Events'; import CompetitionGroup from './pages/Competition/ByGroup/Group'; @@ -82,6 +83,14 @@ const PsychSheet = () => { const Navigation = () => { usePageTracking(import.meta.env.VITE_GA_MEASUREMENT_ID); + useEffect(() => { + if (!('Notification' in window) || Notification.permission !== 'granted') { + return; + } + + void bootstrapAssignmentNotificationChecks(); + }, []); + return ( }> diff --git a/src/hooks/useAssignmentNotifications/index.ts b/src/hooks/useAssignmentNotifications/index.ts new file mode 100644 index 0000000..203cfca --- /dev/null +++ b/src/hooks/useAssignmentNotifications/index.ts @@ -0,0 +1 @@ +export * from './useAssignmentNotifications'; diff --git a/src/hooks/useAssignmentNotifications/useAssignmentNotifications.ts b/src/hooks/useAssignmentNotifications/useAssignmentNotifications.ts new file mode 100644 index 0000000..4f573eb --- /dev/null +++ b/src/hooks/useAssignmentNotifications/useAssignmentNotifications.ts @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + bootstrapAssignmentNotificationChecks, + requestNotificationPermission, +} from '@/lib/notifications/assignmentNotifications'; + +type NotificationStatus = NotificationPermission | 'unsupported'; + +export function useAssignmentNotifications() { + const [status, setStatus] = useState(() => { + if (!('Notification' in window)) { + return 'unsupported'; + } + + return Notification.permission; + }); + const [isEnabling, setIsEnabling] = useState(false); + const [error, setError] = useState(null); + + const enableNotifications = useCallback(async () => { + setIsEnabling(true); + setError(null); + + try { + const permission = await requestNotificationPermission(); + setStatus(permission); + + if (permission === 'granted') { + await bootstrapAssignmentNotificationChecks(); + } + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : 'Unable to enable notifications'); + } finally { + setIsEnabling(false); + } + }, []); + + useEffect(() => { + if (status !== 'granted') { + return; + } + + void bootstrapAssignmentNotificationChecks(); + }, [status]); + + return { + status, + notificationsEnabled: status === 'granted', + isEnabling, + error, + enableNotifications, + }; +} diff --git a/src/lib/notifications/assignmentNotifications.ts b/src/lib/notifications/assignmentNotifications.ts new file mode 100644 index 0000000..38be16b --- /dev/null +++ b/src/lib/notifications/assignmentNotifications.ts @@ -0,0 +1,148 @@ +const ASSIGNMENT_NOTIFICATION_TAG = 'assignment-reminder'; +const CHECK_INTERVAL_MINUTES = 15; +const DEFAULT_NOTIFICATION_SERVICE_URL = '/api/notifications'; + +interface PushSubscriptionJson { + endpoint: string; + keys?: { + p256dh?: string; + auth?: string; + }; +} + +export interface AssignmentNotificationPayload { + title: string; + body: string; + competitionId?: string; + registrantId?: number; + startsAt?: string; +} + +export async function requestNotificationPermission() { + if (!('Notification' in window)) { + return 'unsupported' as const; + } + + if (Notification.permission === 'granted') { + return 'granted' as const; + } + + const permission = await Notification.requestPermission(); + return permission; +} + +function toUint8Array(base64: string) { + const base64Padding = '='.repeat((4 - (base64.length % 4)) % 4); + const normalized = (base64 + base64Padding).replace(/-/g, '+').replace(/_/g, '/'); + const raw = window.atob(normalized); + const output = new Uint8Array(raw.length); + + for (let index = 0; index < raw.length; index += 1) { + output[index] = raw.charCodeAt(index); + } + + return output; +} + +function getNotificationServiceUrl() { + return import.meta.env.VITE_NOTIFICATION_SERVICE_URL ?? DEFAULT_NOTIFICATION_SERVICE_URL; +} + +export async function registerPushSubscription() { + if (!('serviceWorker' in navigator)) { + return null; + } + + const registration = await navigator.serviceWorker.ready; + const existing = await registration.pushManager.getSubscription(); + + if (existing) { + return existing; + } + + const vapidPublicKey = import.meta.env.VITE_VAPID_PUBLIC_KEY; + if (!vapidPublicKey) { + throw new Error('Missing VITE_VAPID_PUBLIC_KEY'); + } + + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: toUint8Array(vapidPublicKey), + }); + + return subscription; +} + +export async function syncSubscriptionWithBackend(subscription: PushSubscription) { + const payload = subscription.toJSON() as PushSubscriptionJson; + + await fetch(`${getNotificationServiceUrl()}/subscriptions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + endpoint: payload.endpoint, + p256dh: payload.keys?.p256dh, + auth: payload.keys?.auth, + }), + }); +} + +export async function showAssignmentNotification(payload: AssignmentNotificationPayload) { + if (!('serviceWorker' in navigator)) { + return; + } + + const registration = await navigator.serviceWorker.ready; + + await registration.showNotification(payload.title, { + body: payload.body, + tag: ASSIGNMENT_NOTIFICATION_TAG, + data: payload, + }); +} + +/** + * Placeholder for background assignment checks. + * + * In a follow-up we should: + * 1) register push subscription against a backend endpoint + * 2) run periodic background checks (where supported) + * 3) postMessage payloads into the SW and display user-facing notifications + */ +export async function bootstrapAssignmentNotificationChecks() { + if (!('serviceWorker' in navigator)) { + return; + } + + const registration = await navigator.serviceWorker.ready; + const subscription = await registerPushSubscription(); + if (subscription) { + await syncSubscriptionWithBackend(subscription); + } + + if ('periodicSync' in registration) { + await ( + registration as ServiceWorkerRegistration & { + periodicSync: { + register: (tag: string, options: { minInterval: number }) => Promise; + }; + } + ).periodicSync.register('assignment-checks', { + minInterval: CHECK_INTERVAL_MINUTES * 60 * 1000, + }); + return; + } + + setInterval( + () => { + registration.active?.postMessage({ + type: 'ASSIGNMENT_CHECK_REQUEST', + requestedAt: new Date().toISOString(), + }); + }, + CHECK_INTERVAL_MINUTES * 60 * 1000, + ); +} diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index 54e917c..d0db705 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -1,8 +1,11 @@ -import { Container } from '@/components'; +import { Button, Container } from '@/components'; +import { useAssignmentNotifications } from '@/hooks/useAssignmentNotifications'; import { Theme, useUserSettings } from '@/providers/UserSettingsProvider'; export default function Settings() { const { theme, setTheme } = useUserSettings(); + const { status, notificationsEnabled, isEnabling, error, enableNotifications } = + useAssignmentNotifications(); const themeOptions: { value: Theme; label: string; description: string }[] = [ { value: 'light', label: 'Light', description: 'Always use light theme' }, @@ -12,30 +15,55 @@ export default function Settings() { return ( -

Settings

+
+

Settings

-
-

Appearance

+
+

Appearance

-
- - {themeOptions.map((option) => ( -
- setTheme(option.value)} - className="radio mt-1" - /> - -
- ))} +
+ + {themeOptions.map((option) => ( +
+ setTheme(option.value)} + className="radio" + /> + +
+ ))} +
+
+ +
+

Assignment notifications

+

+ Enable push notifications for new assignments and upcoming assignment reminders. +

+

Status: {status}

+ {!notificationsEnabled && status !== 'unsupported' && ( + + )} + {error &&

{error}

} + {status === 'unsupported' && ( +

This browser does not support the Notification API.

+ )}
From be87f8aa9c73e9ed5ec657e142ad4a872fe7b56c Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 11 May 2026 10:32:51 -0700 Subject: [PATCH 2/3] Implement notifier subscription API and frontend user wiring --- services/assignment-notifier/README.md | 17 +++- .../assignment-notifier/dist/apiServer.js | 79 ++++++++++++++++ services/assignment-notifier/dist/config.js | 4 + services/assignment-notifier/dist/index.js | 5 +- .../assignment-notifier/dist/pushSender.js | 10 +- services/assignment-notifier/dist/store.js | 41 ++++++++- services/assignment-notifier/src/apiServer.ts | 91 +++++++++++++++++++ services/assignment-notifier/src/config.ts | 8 ++ services/assignment-notifier/src/index.ts | 3 + .../assignment-notifier/src/pushSender.ts | 13 ++- services/assignment-notifier/src/store.ts | 55 +++++++++-- services/assignment-notifier/src/types.ts | 6 ++ src/App.tsx | 5 +- .../useAssignmentNotifications.ts | 10 +- .../notifications/assignmentNotifications.ts | 7 +- src/pages/Settings/index.tsx | 4 +- 16 files changed, 326 insertions(+), 32 deletions(-) create mode 100644 services/assignment-notifier/dist/apiServer.js create mode 100644 services/assignment-notifier/src/apiServer.ts diff --git a/services/assignment-notifier/README.md b/services/assignment-notifier/README.md index 81dae8a..85c7f89 100644 --- a/services/assignment-notifier/README.md +++ b/services/assignment-notifier/README.md @@ -1,6 +1,6 @@ # Assignment Notifier Service (scaffold) -This is a single-service scaffold that polls WCIFs, detects assignment changes, and dispatches placeholder push notifications. +This is a single-service scaffold that polls WCIFs, detects assignment changes, and exposes subscription APIs for push notifications. ## What it includes @@ -8,7 +8,8 @@ This is a single-service scaffold that polls WCIFs, detects assignment changes, - Assignment hash snapshots and diffing - Notification job generation with dedupe keys - Local JSON store for snapshots/subscriptions -- Placeholder push sender (logs output) +- Subscription API server (`/health`, `/subscriptions`) +- Placeholder push sender (ready for Web Push implementation) ## Configure @@ -18,6 +19,16 @@ Create environment variables: - `WCIF_COMPETITION_IDS` - comma-separated competition IDs - `WCIF_API_BASE_URL` (optional, defaults to WCA v0 API) - `WCIF_POLL_INTERVAL_MS` (optional, defaults to 300000) +- `NOTIFIER_API_PORT` (optional, defaults to 8787) +- `VAPID_SUBJECT` (optional, e.g. `mailto:ops@example.com`) +- `VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY` + +## API + +- `GET /health` +- `GET /subscriptions` +- `POST /subscriptions` with `{ userId, endpoint, p256dh, auth }` +- `DELETE /subscriptions` with `{ endpoint }` ## Run @@ -28,6 +39,6 @@ yarn notify:service ## Next steps - Replace `sendPushNotifications` with actual Web Push delivery. -- Add API routes to register/remove subscriptions. - Move store to a persistent DB. - Add upcoming-assignment reminder jobs. +- Add auth middleware for subscription API routes. diff --git a/services/assignment-notifier/dist/apiServer.js b/services/assignment-notifier/dist/apiServer.js new file mode 100644 index 0000000..43bf37b --- /dev/null +++ b/services/assignment-notifier/dist/apiServer.js @@ -0,0 +1,79 @@ +'use strict'; +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, '__esModule', { value: true }); +exports.startNotifierApiServer = startNotifierApiServer; +const http_1 = __importDefault(require('http')); +const store_1 = require('./store'); +function parseBody(body) { + try { + return JSON.parse(body); + } catch { + return {}; + } +} +function startNotifierApiServer(config) { + const server = http_1.default.createServer((req, res) => { + const url = req.url ?? ''; + if (req.method === 'GET' && url === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + return; + } + if (req.method === 'GET' && url === '/subscriptions') { + void (0, store_1.readStore)().then((store) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(store.subscriptions)); + }); + return; + } + if (req.method === 'POST' && url === '/subscriptions') { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + const payload = parseBody(Buffer.concat(chunks).toString('utf8')); + if (!payload.endpoint || !payload.p256dh || !payload.auth || !payload.userId) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing endpoint, p256dh, auth, or userId' })); + return; + } + void (0, store_1.upsertSubscription)({ + userId: payload.userId, + endpoint: payload.endpoint, + p256dh: payload.p256dh, + auth: payload.auth, + }).then(() => { + res.writeHead(201, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + }); + return; + } + if (req.method === 'DELETE' && url === '/subscriptions') { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + const payload = parseBody(Buffer.concat(chunks).toString('utf8')); + if (!payload.endpoint) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing endpoint' })); + return; + } + void (0, store_1.deleteSubscription)(payload.endpoint).then(() => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + }); + return; + } + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); + }); + server.listen(config.apiPort, () => { + console.log(`[push-notifier] API listening on :${config.apiPort}`); + }); + return server; +} diff --git a/services/assignment-notifier/dist/config.js b/services/assignment-notifier/dist/config.js index 01e282d..88ab2e4 100644 --- a/services/assignment-notifier/dist/config.js +++ b/services/assignment-notifier/dist/config.js @@ -19,5 +19,9 @@ function getNotifierServiceConfig() { wcifApiBaseUrl: process.env.WCIF_API_BASE_URL ?? 'https://www.worldcubeassociation.org/api/v0', apiToken: process.env.WCA_OAUTH_TOKEN ?? '', competitionIds, + apiPort: readNumber('NOTIFIER_API_PORT', 8787), + vapidSubject: process.env.VAPID_SUBJECT ?? 'mailto:notifications@example.com', + vapidPublicKey: process.env.VAPID_PUBLIC_KEY ?? '', + vapidPrivateKey: process.env.VAPID_PRIVATE_KEY ?? '', }; } diff --git a/services/assignment-notifier/dist/index.js b/services/assignment-notifier/dist/index.js index 6e19863..6aafa24 100644 --- a/services/assignment-notifier/dist/index.js +++ b/services/assignment-notifier/dist/index.js @@ -1,7 +1,8 @@ 'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); -const diff_1 = require('./diff'); +const apiServer_1 = require('./apiServer'); const config_1 = require('./config'); +const diff_1 = require('./diff'); const pushSender_1 = require('./pushSender'); const store_1 = require('./store'); const wcifClient_1 = require('./wcifClient'); @@ -23,6 +24,7 @@ async function runOnce() { await (0, pushSender_1.sendPushNotifications)({ jobs, subscriptions: store.subscriptions, + config, }); store.snapshots = [ ...store.snapshots.filter((snapshot) => snapshot.competitionId !== competitionId), @@ -34,6 +36,7 @@ async function runOnce() { } async function main() { const config = (0, config_1.getNotifierServiceConfig)(); + (0, apiServer_1.startNotifierApiServer)(config); await runOnce(); if (process.env.NOTIFIER_RUN_ONCE === 'true') { return; diff --git a/services/assignment-notifier/dist/pushSender.js b/services/assignment-notifier/dist/pushSender.js index d24a062..04c149b 100644 --- a/services/assignment-notifier/dist/pushSender.js +++ b/services/assignment-notifier/dist/pushSender.js @@ -2,16 +2,22 @@ Object.defineProperty(exports, '__esModule', { value: true }); exports.sendPushNotifications = sendPushNotifications; async function sendPushNotifications(params) { + if (!params.config.vapidPublicKey || !params.config.vapidPrivateKey) { + console.warn('[push-notifier] Skipping sends: missing VAPID keys'); + return; + } for (const job of params.jobs) { const subscriptions = params.subscriptions.filter( (subscription) => subscription.userId === job.userId, ); for (const subscription of subscriptions) { console.log( - '[push-notifier] send placeholder push', + '[push-notifier] TODO: send web push payload', JSON.stringify({ - userId: subscription.userId, endpoint: subscription.endpoint, + userId: subscription.userId, + title: job.title, + body: job.body, competitionId: job.competitionId, dedupeKey: job.dedupeKey, }), diff --git a/services/assignment-notifier/dist/store.js b/services/assignment-notifier/dist/store.js index 01a7da2..0294e96 100644 --- a/services/assignment-notifier/dist/store.js +++ b/services/assignment-notifier/dist/store.js @@ -7,12 +7,12 @@ var __importDefault = Object.defineProperty(exports, '__esModule', { value: true }); exports.readStore = readStore; exports.saveStore = saveStore; +exports.upsertSubscription = upsertSubscription; +exports.deleteSubscription = deleteSubscription; const promises_1 = require('fs/promises'); const path_1 = __importDefault(require('path')); -const STORE_PATH = path_1.default.resolve( - process.cwd(), - 'services/assignment-notifier/data/store.json', -); +const STORE_DIR = path_1.default.resolve(process.cwd(), 'services/assignment-notifier/data'); +const STORE_PATH = path_1.default.resolve(STORE_DIR, 'store.json'); async function ensureStore() { try { const content = await (0, promises_1.readFile)(STORE_PATH, 'utf8'); @@ -29,5 +29,38 @@ async function readStore() { return ensureStore(); } async function saveStore(store) { + await (0, promises_1.mkdir)(STORE_DIR, { recursive: true }); await (0, promises_1.writeFile)(STORE_PATH, JSON.stringify(store, null, 2), 'utf8'); } +async function upsertSubscription(params) { + const store = await readStore(); + const now = new Date().toISOString(); + const existing = store.subscriptions.find( + (subscription) => subscription.endpoint === params.endpoint, + ); + if (existing) { + existing.userId = params.userId; + existing.p256dh = params.p256dh; + existing.auth = params.auth; + existing.updatedAt = now; + } else { + const next = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + userId: params.userId, + endpoint: params.endpoint, + p256dh: params.p256dh, + auth: params.auth, + createdAt: now, + updatedAt: now, + }; + store.subscriptions.push(next); + } + await saveStore(store); +} +async function deleteSubscription(endpoint) { + const store = await readStore(); + store.subscriptions = store.subscriptions.filter( + (subscription) => subscription.endpoint !== endpoint, + ); + await saveStore(store); +} diff --git a/services/assignment-notifier/src/apiServer.ts b/services/assignment-notifier/src/apiServer.ts new file mode 100644 index 0000000..577ae0a --- /dev/null +++ b/services/assignment-notifier/src/apiServer.ts @@ -0,0 +1,91 @@ +import http from 'http'; +import type { NotifierServiceConfig } from './config'; +import { deleteSubscription, readStore, upsertSubscription } from './store'; + +interface SubscriptionPayload { + endpoint?: string; + p256dh?: string; + auth?: string; + userId?: number; +} + +function parseBody(body: string): SubscriptionPayload { + try { + return JSON.parse(body) as SubscriptionPayload; + } catch { + return {}; + } +} + +export function startNotifierApiServer(config: NotifierServiceConfig) { + const server = http.createServer((req, res) => { + const url = req.url ?? ''; + + if (req.method === 'GET' && url === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + return; + } + + if (req.method === 'GET' && url === '/subscriptions') { + void readStore().then((store) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(store.subscriptions)); + }); + return; + } + + if (req.method === 'POST' && url === '/subscriptions') { + const chunks: Buffer[] = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + const payload = parseBody(Buffer.concat(chunks).toString('utf8')); + + if (!payload.endpoint || !payload.p256dh || !payload.auth || !payload.userId) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing endpoint, p256dh, auth, or userId' })); + return; + } + + void upsertSubscription({ + userId: payload.userId, + endpoint: payload.endpoint, + p256dh: payload.p256dh, + auth: payload.auth, + }).then(() => { + res.writeHead(201, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + }); + return; + } + + if (req.method === 'DELETE' && url === '/subscriptions') { + const chunks: Buffer[] = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + const payload = parseBody(Buffer.concat(chunks).toString('utf8')); + if (!payload.endpoint) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing endpoint' })); + return; + } + + void deleteSubscription(payload.endpoint).then(() => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + }); + return; + } + + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); + }); + + server.listen(config.apiPort, () => { + console.log(`[push-notifier] API listening on :${config.apiPort}`); + }); + + return server; +} diff --git a/services/assignment-notifier/src/config.ts b/services/assignment-notifier/src/config.ts index db1460a..44473e4 100644 --- a/services/assignment-notifier/src/config.ts +++ b/services/assignment-notifier/src/config.ts @@ -3,6 +3,10 @@ export interface NotifierServiceConfig { wcifApiBaseUrl: string; apiToken: string; competitionIds: string[]; + apiPort: number; + vapidSubject: string; + vapidPublicKey: string; + vapidPrivateKey: string; } function readNumber(name: string, fallback: number) { @@ -26,5 +30,9 @@ export function getNotifierServiceConfig(): NotifierServiceConfig { wcifApiBaseUrl: process.env.WCIF_API_BASE_URL ?? 'https://www.worldcubeassociation.org/api/v0', apiToken: process.env.WCA_OAUTH_TOKEN ?? '', competitionIds, + apiPort: readNumber('NOTIFIER_API_PORT', 8787), + vapidSubject: process.env.VAPID_SUBJECT ?? 'mailto:notifications@example.com', + vapidPublicKey: process.env.VAPID_PUBLIC_KEY ?? '', + vapidPrivateKey: process.env.VAPID_PRIVATE_KEY ?? '', }; } diff --git a/services/assignment-notifier/src/index.ts b/services/assignment-notifier/src/index.ts index 28140b7..6780e25 100644 --- a/services/assignment-notifier/src/index.ts +++ b/services/assignment-notifier/src/index.ts @@ -1,3 +1,4 @@ +import { startNotifierApiServer } from './apiServer'; import { getNotifierServiceConfig } from './config'; import { buildNotificationJobs, createAssignmentSnapshots } from './diff'; import { sendPushNotifications } from './pushSender'; @@ -26,6 +27,7 @@ async function runOnce() { await sendPushNotifications({ jobs, subscriptions: store.subscriptions, + config, }); store.snapshots = [ @@ -40,6 +42,7 @@ async function runOnce() { async function main() { const config = getNotifierServiceConfig(); + startNotifierApiServer(config); await runOnce(); diff --git a/services/assignment-notifier/src/pushSender.ts b/services/assignment-notifier/src/pushSender.ts index a9c01a6..c9e8276 100644 --- a/services/assignment-notifier/src/pushSender.ts +++ b/services/assignment-notifier/src/pushSender.ts @@ -1,9 +1,16 @@ +import type { NotifierServiceConfig } from './config'; import type { NotificationJob, PushSubscriptionRecord } from './types'; export async function sendPushNotifications(params: { jobs: NotificationJob[]; subscriptions: PushSubscriptionRecord[]; + config: NotifierServiceConfig; }) { + if (!params.config.vapidPublicKey || !params.config.vapidPrivateKey) { + console.warn('[push-notifier] Skipping sends: missing VAPID keys'); + return; + } + for (const job of params.jobs) { const subscriptions = params.subscriptions.filter( (subscription) => subscription.userId === job.userId, @@ -11,10 +18,12 @@ export async function sendPushNotifications(params: { for (const subscription of subscriptions) { console.log( - '[push-notifier] send placeholder push', + '[push-notifier] TODO: send web push payload', JSON.stringify({ - userId: subscription.userId, endpoint: subscription.endpoint, + userId: subscription.userId, + title: job.title, + body: job.body, competitionId: job.competitionId, dedupeKey: job.dedupeKey, }), diff --git a/services/assignment-notifier/src/store.ts b/services/assignment-notifier/src/store.ts index f2adb94..11c68f6 100644 --- a/services/assignment-notifier/src/store.ts +++ b/services/assignment-notifier/src/store.ts @@ -1,14 +1,9 @@ -import { readFile, writeFile } from 'fs/promises'; +import { mkdir, readFile, writeFile } from 'fs/promises'; import path from 'path'; -import type { AssignmentSnapshot, PushSubscriptionRecord } from './types'; +import type { PushSubscriptionRecord, ServiceStore } from './types'; -interface ServiceStore { - snapshots: AssignmentSnapshot[]; - subscriptions: PushSubscriptionRecord[]; - deliveredDedupeKeys: string[]; -} - -const STORE_PATH = path.resolve(process.cwd(), 'services/assignment-notifier/data/store.json'); +const STORE_DIR = path.resolve(process.cwd(), 'services/assignment-notifier/data'); +const STORE_PATH = path.resolve(STORE_DIR, 'store.json'); async function ensureStore(): Promise { try { @@ -28,5 +23,47 @@ export async function readStore() { } export async function saveStore(store: ServiceStore) { + await mkdir(STORE_DIR, { recursive: true }); await writeFile(STORE_PATH, JSON.stringify(store, null, 2), 'utf8'); } + +export async function upsertSubscription(params: { + userId: number; + endpoint: string; + p256dh: string; + auth: string; +}) { + const store = await readStore(); + const now = new Date().toISOString(); + const existing = store.subscriptions.find( + (subscription) => subscription.endpoint === params.endpoint, + ); + + if (existing) { + existing.userId = params.userId; + existing.p256dh = params.p256dh; + existing.auth = params.auth; + existing.updatedAt = now; + } else { + const next: PushSubscriptionRecord = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + userId: params.userId, + endpoint: params.endpoint, + p256dh: params.p256dh, + auth: params.auth, + createdAt: now, + updatedAt: now, + }; + store.subscriptions.push(next); + } + + await saveStore(store); +} + +export async function deleteSubscription(endpoint: string) { + const store = await readStore(); + store.subscriptions = store.subscriptions.filter( + (subscription) => subscription.endpoint !== endpoint, + ); + await saveStore(store); +} diff --git a/services/assignment-notifier/src/types.ts b/services/assignment-notifier/src/types.ts index fd45311..c4f674e 100644 --- a/services/assignment-notifier/src/types.ts +++ b/services/assignment-notifier/src/types.ts @@ -36,3 +36,9 @@ export interface WcifPayload { name: string; persons: WcifPerson[]; } + +export interface ServiceStore { + snapshots: AssignmentSnapshot[]; + subscriptions: PushSubscriptionRecord[]; + deliveredDedupeKeys: string[]; +} diff --git a/src/App.tsx b/src/App.tsx index 245bd5c..5547ef2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -82,14 +82,15 @@ const PsychSheet = () => { const Navigation = () => { usePageTracking(import.meta.env.VITE_GA_MEASUREMENT_ID); + const { user } = useAuth(); useEffect(() => { if (!('Notification' in window) || Notification.permission !== 'granted') { return; } - void bootstrapAssignmentNotificationChecks(); - }, []); + void bootstrapAssignmentNotificationChecks(user?.id); + }, [user?.id]); return ( diff --git a/src/hooks/useAssignmentNotifications/useAssignmentNotifications.ts b/src/hooks/useAssignmentNotifications/useAssignmentNotifications.ts index 4f573eb..144a3da 100644 --- a/src/hooks/useAssignmentNotifications/useAssignmentNotifications.ts +++ b/src/hooks/useAssignmentNotifications/useAssignmentNotifications.ts @@ -6,7 +6,7 @@ import { type NotificationStatus = NotificationPermission | 'unsupported'; -export function useAssignmentNotifications() { +export function useAssignmentNotifications(userId?: number) { const [status, setStatus] = useState(() => { if (!('Notification' in window)) { return 'unsupported'; @@ -26,22 +26,22 @@ export function useAssignmentNotifications() { setStatus(permission); if (permission === 'granted') { - await bootstrapAssignmentNotificationChecks(); + await bootstrapAssignmentNotificationChecks(userId); } } catch (nextError) { setError(nextError instanceof Error ? nextError.message : 'Unable to enable notifications'); } finally { setIsEnabling(false); } - }, []); + }, [userId]); useEffect(() => { if (status !== 'granted') { return; } - void bootstrapAssignmentNotificationChecks(); - }, [status]); + void bootstrapAssignmentNotificationChecks(userId); + }, [status, userId]); return { status, diff --git a/src/lib/notifications/assignmentNotifications.ts b/src/lib/notifications/assignmentNotifications.ts index 38be16b..a74728c 100644 --- a/src/lib/notifications/assignmentNotifications.ts +++ b/src/lib/notifications/assignmentNotifications.ts @@ -73,7 +73,7 @@ export async function registerPushSubscription() { return subscription; } -export async function syncSubscriptionWithBackend(subscription: PushSubscription) { +export async function syncSubscriptionWithBackend(subscription: PushSubscription, userId?: number) { const payload = subscription.toJSON() as PushSubscriptionJson; await fetch(`${getNotificationServiceUrl()}/subscriptions`, { @@ -83,6 +83,7 @@ export async function syncSubscriptionWithBackend(subscription: PushSubscription }, credentials: 'include', body: JSON.stringify({ + userId, endpoint: payload.endpoint, p256dh: payload.keys?.p256dh, auth: payload.keys?.auth, @@ -112,7 +113,7 @@ export async function showAssignmentNotification(payload: AssignmentNotification * 2) run periodic background checks (where supported) * 3) postMessage payloads into the SW and display user-facing notifications */ -export async function bootstrapAssignmentNotificationChecks() { +export async function bootstrapAssignmentNotificationChecks(userId?: number) { if (!('serviceWorker' in navigator)) { return; } @@ -120,7 +121,7 @@ export async function bootstrapAssignmentNotificationChecks() { const registration = await navigator.serviceWorker.ready; const subscription = await registerPushSubscription(); if (subscription) { - await syncSubscriptionWithBackend(subscription); + await syncSubscriptionWithBackend(subscription, userId); } if ('periodicSync' in registration) { diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index d0db705..2c69df8 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -1,11 +1,13 @@ import { Button, Container } from '@/components'; import { useAssignmentNotifications } from '@/hooks/useAssignmentNotifications'; +import { useAuth } from '@/providers/AuthProvider'; import { Theme, useUserSettings } from '@/providers/UserSettingsProvider'; export default function Settings() { const { theme, setTheme } = useUserSettings(); + const { user } = useAuth(); const { status, notificationsEnabled, isEnabling, error, enableNotifications } = - useAssignmentNotifications(); + useAssignmentNotifications(user?.id); const themeOptions: { value: Theme; label: string; description: string }[] = [ { value: 'light', label: 'Light', description: 'Always use light theme' }, From 3670917ca386abc0ed7f0956e0e7597899c83c93 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 11 May 2026 11:03:27 -0700 Subject: [PATCH 3/3] Remove generated notifier artifacts from source control --- .gitignore | 2 + services/assignment-notifier/data/store.json | 5 -- .../assignment-notifier/dist/apiServer.js | 79 ------------------- services/assignment-notifier/dist/config.js | 27 ------- services/assignment-notifier/dist/diff.js | 47 ----------- services/assignment-notifier/dist/index.js | 48 ----------- .../assignment-notifier/dist/pushSender.js | 27 ------- services/assignment-notifier/dist/store.js | 66 ---------------- services/assignment-notifier/dist/types.js | 2 - .../assignment-notifier/dist/wcifClient.js | 14 ---- 10 files changed, 2 insertions(+), 315 deletions(-) delete mode 100644 services/assignment-notifier/data/store.json delete mode 100644 services/assignment-notifier/dist/apiServer.js delete mode 100644 services/assignment-notifier/dist/config.js delete mode 100644 services/assignment-notifier/dist/diff.js delete mode 100644 services/assignment-notifier/dist/index.js delete mode 100644 services/assignment-notifier/dist/pushSender.js delete mode 100644 services/assignment-notifier/dist/store.js delete mode 100644 services/assignment-notifier/dist/types.js delete mode 100644 services/assignment-notifier/dist/wcifClient.js diff --git a/.gitignore b/.gitignore index fb7a4a0..7dcf333 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ # production /dist +/services/assignment-notifier/dist +/services/assignment-notifier/data/store.json # vite pwa dev /dev-dist diff --git a/services/assignment-notifier/data/store.json b/services/assignment-notifier/data/store.json deleted file mode 100644 index 0ea44ac..0000000 --- a/services/assignment-notifier/data/store.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "snapshots": [], - "subscriptions": [], - "deliveredDedupeKeys": [] -} diff --git a/services/assignment-notifier/dist/apiServer.js b/services/assignment-notifier/dist/apiServer.js deleted file mode 100644 index 43bf37b..0000000 --- a/services/assignment-notifier/dist/apiServer.js +++ /dev/null @@ -1,79 +0,0 @@ -'use strict'; -var __importDefault = - (this && this.__importDefault) || - function (mod) { - return mod && mod.__esModule ? mod : { default: mod }; - }; -Object.defineProperty(exports, '__esModule', { value: true }); -exports.startNotifierApiServer = startNotifierApiServer; -const http_1 = __importDefault(require('http')); -const store_1 = require('./store'); -function parseBody(body) { - try { - return JSON.parse(body); - } catch { - return {}; - } -} -function startNotifierApiServer(config) { - const server = http_1.default.createServer((req, res) => { - const url = req.url ?? ''; - if (req.method === 'GET' && url === '/health') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: true })); - return; - } - if (req.method === 'GET' && url === '/subscriptions') { - void (0, store_1.readStore)().then((store) => { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(store.subscriptions)); - }); - return; - } - if (req.method === 'POST' && url === '/subscriptions') { - const chunks = []; - req.on('data', (chunk) => chunks.push(chunk)); - req.on('end', () => { - const payload = parseBody(Buffer.concat(chunks).toString('utf8')); - if (!payload.endpoint || !payload.p256dh || !payload.auth || !payload.userId) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Missing endpoint, p256dh, auth, or userId' })); - return; - } - void (0, store_1.upsertSubscription)({ - userId: payload.userId, - endpoint: payload.endpoint, - p256dh: payload.p256dh, - auth: payload.auth, - }).then(() => { - res.writeHead(201, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: true })); - }); - }); - return; - } - if (req.method === 'DELETE' && url === '/subscriptions') { - const chunks = []; - req.on('data', (chunk) => chunks.push(chunk)); - req.on('end', () => { - const payload = parseBody(Buffer.concat(chunks).toString('utf8')); - if (!payload.endpoint) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Missing endpoint' })); - return; - } - void (0, store_1.deleteSubscription)(payload.endpoint).then(() => { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: true })); - }); - }); - return; - } - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Not found' })); - }); - server.listen(config.apiPort, () => { - console.log(`[push-notifier] API listening on :${config.apiPort}`); - }); - return server; -} diff --git a/services/assignment-notifier/dist/config.js b/services/assignment-notifier/dist/config.js deleted file mode 100644 index 88ab2e4..0000000 --- a/services/assignment-notifier/dist/config.js +++ /dev/null @@ -1,27 +0,0 @@ -'use strict'; -Object.defineProperty(exports, '__esModule', { value: true }); -exports.getNotifierServiceConfig = getNotifierServiceConfig; -function readNumber(name, fallback) { - const raw = process.env[name]; - if (!raw) { - return fallback; - } - const parsed = Number(raw); - return Number.isFinite(parsed) ? parsed : fallback; -} -function getNotifierServiceConfig() { - const competitionIds = (process.env.WCIF_COMPETITION_IDS ?? '') - .split(',') - .map((part) => part.trim()) - .filter(Boolean); - return { - wcifPollIntervalMs: readNumber('WCIF_POLL_INTERVAL_MS', 5 * 60 * 1000), - wcifApiBaseUrl: process.env.WCIF_API_BASE_URL ?? 'https://www.worldcubeassociation.org/api/v0', - apiToken: process.env.WCA_OAUTH_TOKEN ?? '', - competitionIds, - apiPort: readNumber('NOTIFIER_API_PORT', 8787), - vapidSubject: process.env.VAPID_SUBJECT ?? 'mailto:notifications@example.com', - vapidPublicKey: process.env.VAPID_PUBLIC_KEY ?? '', - vapidPrivateKey: process.env.VAPID_PRIVATE_KEY ?? '', - }; -} diff --git a/services/assignment-notifier/dist/diff.js b/services/assignment-notifier/dist/diff.js deleted file mode 100644 index 322a54d..0000000 --- a/services/assignment-notifier/dist/diff.js +++ /dev/null @@ -1,47 +0,0 @@ -'use strict'; -Object.defineProperty(exports, '__esModule', { value: true }); -exports.createAssignmentSnapshots = createAssignmentSnapshots; -exports.buildNotificationJobs = buildNotificationJobs; -const crypto_1 = require('crypto'); -function hashAssignments(value) { - return (0, crypto_1.createHash)('sha256') - .update(JSON.stringify(value ?? null)) - .digest('hex'); -} -function createAssignmentSnapshots(wcif) { - return wcif.persons - .filter((person) => person.wcaUserId) - .map((person) => ({ - competitionId: wcif.id, - personWcaUserId: person.wcaUserId, - assignmentsHash: hashAssignments(person.assignments), - fetchedAt: new Date().toISOString(), - })); -} -function buildNotificationJobs(params) { - const previousByUser = new Map( - params.previousSnapshots.map((snapshot) => [ - `${snapshot.competitionId}:${snapshot.personWcaUserId}`, - snapshot, - ]), - ); - return params.nextSnapshots.flatMap((snapshot) => { - const id = `${snapshot.competitionId}:${snapshot.personWcaUserId}`; - const previous = previousByUser.get(id); - if (!previous || previous.assignmentsHash === snapshot.assignmentsHash) { - return []; - } - const dedupeKey = `assignment-update:${id}:${snapshot.assignmentsHash}`; - return [ - { - id: `${Date.now()}:${id}`, - userId: snapshot.personWcaUserId, - competitionId: snapshot.competitionId, - title: 'New assignment update', - body: 'Your assignments were updated. Open the app to review the latest groups.', - dedupeKey, - createdAt: new Date().toISOString(), - }, - ]; - }); -} diff --git a/services/assignment-notifier/dist/index.js b/services/assignment-notifier/dist/index.js deleted file mode 100644 index 6aafa24..0000000 --- a/services/assignment-notifier/dist/index.js +++ /dev/null @@ -1,48 +0,0 @@ -'use strict'; -Object.defineProperty(exports, '__esModule', { value: true }); -const apiServer_1 = require('./apiServer'); -const config_1 = require('./config'); -const diff_1 = require('./diff'); -const pushSender_1 = require('./pushSender'); -const store_1 = require('./store'); -const wcifClient_1 = require('./wcifClient'); -async function runOnce() { - const config = (0, config_1.getNotifierServiceConfig)(); - const store = await (0, store_1.readStore)(); - for (const competitionId of config.competitionIds) { - const wcif = await (0, wcifClient_1.fetchWcif)(competitionId, { - apiBaseUrl: config.wcifApiBaseUrl, - token: config.apiToken, - }); - const nextSnapshots = (0, diff_1.createAssignmentSnapshots)(wcif); - const previousSnapshots = store.snapshots.filter( - (snapshot) => snapshot.competitionId === competitionId, - ); - const jobs = (0, diff_1.buildNotificationJobs)({ previousSnapshots, nextSnapshots }).filter( - (job) => !store.deliveredDedupeKeys.includes(job.dedupeKey), - ); - await (0, pushSender_1.sendPushNotifications)({ - jobs, - subscriptions: store.subscriptions, - config, - }); - store.snapshots = [ - ...store.snapshots.filter((snapshot) => snapshot.competitionId !== competitionId), - ...nextSnapshots, - ]; - store.deliveredDedupeKeys.push(...jobs.map((job) => job.dedupeKey)); - } - await (0, store_1.saveStore)(store); -} -async function main() { - const config = (0, config_1.getNotifierServiceConfig)(); - (0, apiServer_1.startNotifierApiServer)(config); - await runOnce(); - if (process.env.NOTIFIER_RUN_ONCE === 'true') { - return; - } - setInterval(() => { - void runOnce(); - }, config.wcifPollIntervalMs); -} -void main(); diff --git a/services/assignment-notifier/dist/pushSender.js b/services/assignment-notifier/dist/pushSender.js deleted file mode 100644 index 04c149b..0000000 --- a/services/assignment-notifier/dist/pushSender.js +++ /dev/null @@ -1,27 +0,0 @@ -'use strict'; -Object.defineProperty(exports, '__esModule', { value: true }); -exports.sendPushNotifications = sendPushNotifications; -async function sendPushNotifications(params) { - if (!params.config.vapidPublicKey || !params.config.vapidPrivateKey) { - console.warn('[push-notifier] Skipping sends: missing VAPID keys'); - return; - } - for (const job of params.jobs) { - const subscriptions = params.subscriptions.filter( - (subscription) => subscription.userId === job.userId, - ); - for (const subscription of subscriptions) { - console.log( - '[push-notifier] TODO: send web push payload', - JSON.stringify({ - endpoint: subscription.endpoint, - userId: subscription.userId, - title: job.title, - body: job.body, - competitionId: job.competitionId, - dedupeKey: job.dedupeKey, - }), - ); - } - } -} diff --git a/services/assignment-notifier/dist/store.js b/services/assignment-notifier/dist/store.js deleted file mode 100644 index 0294e96..0000000 --- a/services/assignment-notifier/dist/store.js +++ /dev/null @@ -1,66 +0,0 @@ -'use strict'; -var __importDefault = - (this && this.__importDefault) || - function (mod) { - return mod && mod.__esModule ? mod : { default: mod }; - }; -Object.defineProperty(exports, '__esModule', { value: true }); -exports.readStore = readStore; -exports.saveStore = saveStore; -exports.upsertSubscription = upsertSubscription; -exports.deleteSubscription = deleteSubscription; -const promises_1 = require('fs/promises'); -const path_1 = __importDefault(require('path')); -const STORE_DIR = path_1.default.resolve(process.cwd(), 'services/assignment-notifier/data'); -const STORE_PATH = path_1.default.resolve(STORE_DIR, 'store.json'); -async function ensureStore() { - try { - const content = await (0, promises_1.readFile)(STORE_PATH, 'utf8'); - return JSON.parse(content); - } catch { - return { - snapshots: [], - subscriptions: [], - deliveredDedupeKeys: [], - }; - } -} -async function readStore() { - return ensureStore(); -} -async function saveStore(store) { - await (0, promises_1.mkdir)(STORE_DIR, { recursive: true }); - await (0, promises_1.writeFile)(STORE_PATH, JSON.stringify(store, null, 2), 'utf8'); -} -async function upsertSubscription(params) { - const store = await readStore(); - const now = new Date().toISOString(); - const existing = store.subscriptions.find( - (subscription) => subscription.endpoint === params.endpoint, - ); - if (existing) { - existing.userId = params.userId; - existing.p256dh = params.p256dh; - existing.auth = params.auth; - existing.updatedAt = now; - } else { - const next = { - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - userId: params.userId, - endpoint: params.endpoint, - p256dh: params.p256dh, - auth: params.auth, - createdAt: now, - updatedAt: now, - }; - store.subscriptions.push(next); - } - await saveStore(store); -} -async function deleteSubscription(endpoint) { - const store = await readStore(); - store.subscriptions = store.subscriptions.filter( - (subscription) => subscription.endpoint !== endpoint, - ); - await saveStore(store); -} diff --git a/services/assignment-notifier/dist/types.js b/services/assignment-notifier/dist/types.js deleted file mode 100644 index db8b17d..0000000 --- a/services/assignment-notifier/dist/types.js +++ /dev/null @@ -1,2 +0,0 @@ -'use strict'; -Object.defineProperty(exports, '__esModule', { value: true }); diff --git a/services/assignment-notifier/dist/wcifClient.js b/services/assignment-notifier/dist/wcifClient.js deleted file mode 100644 index adecf64..0000000 --- a/services/assignment-notifier/dist/wcifClient.js +++ /dev/null @@ -1,14 +0,0 @@ -'use strict'; -Object.defineProperty(exports, '__esModule', { value: true }); -exports.fetchWcif = fetchWcif; -async function fetchWcif(competitionId, options) { - const response = await fetch(`${options.apiBaseUrl}/competitions/${competitionId}/wcif`, { - headers: { - Authorization: `Bearer ${options.token}`, - }, - }); - if (!response.ok) { - throw new Error(`Failed to fetch WCIF for ${competitionId}: ${response.status}`); - } - return await response.json(); -}