Skip to content

Commit be87f8a

Browse files
committed
Implement notifier subscription API and frontend user wiring
1 parent 6d78f11 commit be87f8a

16 files changed

Lines changed: 326 additions & 32 deletions

File tree

services/assignment-notifier/README.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
# Assignment Notifier Service (scaffold)
22

3-
This is a single-service scaffold that polls WCIFs, detects assignment changes, and dispatches placeholder push notifications.
3+
This is a single-service scaffold that polls WCIFs, detects assignment changes, and exposes subscription APIs for push notifications.
44

55
## What it includes
66

77
- WCIF polling loop
88
- Assignment hash snapshots and diffing
99
- Notification job generation with dedupe keys
1010
- Local JSON store for snapshots/subscriptions
11-
- Placeholder push sender (logs output)
11+
- Subscription API server (`/health`, `/subscriptions`)
12+
- Placeholder push sender (ready for Web Push implementation)
1213

1314
## Configure
1415

@@ -18,6 +19,16 @@ Create environment variables:
1819
- `WCIF_COMPETITION_IDS` - comma-separated competition IDs
1920
- `WCIF_API_BASE_URL` (optional, defaults to WCA v0 API)
2021
- `WCIF_POLL_INTERVAL_MS` (optional, defaults to 300000)
22+
- `NOTIFIER_API_PORT` (optional, defaults to 8787)
23+
- `VAPID_SUBJECT` (optional, e.g. `mailto:ops@example.com`)
24+
- `VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY`
25+
26+
## API
27+
28+
- `GET /health`
29+
- `GET /subscriptions`
30+
- `POST /subscriptions` with `{ userId, endpoint, p256dh, auth }`
31+
- `DELETE /subscriptions` with `{ endpoint }`
2132

2233
## Run
2334

@@ -28,6 +39,6 @@ yarn notify:service
2839
## Next steps
2940

3041
- Replace `sendPushNotifications` with actual Web Push delivery.
31-
- Add API routes to register/remove subscriptions.
3242
- Move store to a persistent DB.
3343
- Add upcoming-assignment reminder jobs.
44+
- Add auth middleware for subscription API routes.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
'use strict';
2+
var __importDefault =
3+
(this && this.__importDefault) ||
4+
function (mod) {
5+
return mod && mod.__esModule ? mod : { default: mod };
6+
};
7+
Object.defineProperty(exports, '__esModule', { value: true });
8+
exports.startNotifierApiServer = startNotifierApiServer;
9+
const http_1 = __importDefault(require('http'));
10+
const store_1 = require('./store');
11+
function parseBody(body) {
12+
try {
13+
return JSON.parse(body);
14+
} catch {
15+
return {};
16+
}
17+
}
18+
function startNotifierApiServer(config) {
19+
const server = http_1.default.createServer((req, res) => {
20+
const url = req.url ?? '';
21+
if (req.method === 'GET' && url === '/health') {
22+
res.writeHead(200, { 'Content-Type': 'application/json' });
23+
res.end(JSON.stringify({ ok: true }));
24+
return;
25+
}
26+
if (req.method === 'GET' && url === '/subscriptions') {
27+
void (0, store_1.readStore)().then((store) => {
28+
res.writeHead(200, { 'Content-Type': 'application/json' });
29+
res.end(JSON.stringify(store.subscriptions));
30+
});
31+
return;
32+
}
33+
if (req.method === 'POST' && url === '/subscriptions') {
34+
const chunks = [];
35+
req.on('data', (chunk) => chunks.push(chunk));
36+
req.on('end', () => {
37+
const payload = parseBody(Buffer.concat(chunks).toString('utf8'));
38+
if (!payload.endpoint || !payload.p256dh || !payload.auth || !payload.userId) {
39+
res.writeHead(400, { 'Content-Type': 'application/json' });
40+
res.end(JSON.stringify({ error: 'Missing endpoint, p256dh, auth, or userId' }));
41+
return;
42+
}
43+
void (0, store_1.upsertSubscription)({
44+
userId: payload.userId,
45+
endpoint: payload.endpoint,
46+
p256dh: payload.p256dh,
47+
auth: payload.auth,
48+
}).then(() => {
49+
res.writeHead(201, { 'Content-Type': 'application/json' });
50+
res.end(JSON.stringify({ ok: true }));
51+
});
52+
});
53+
return;
54+
}
55+
if (req.method === 'DELETE' && url === '/subscriptions') {
56+
const chunks = [];
57+
req.on('data', (chunk) => chunks.push(chunk));
58+
req.on('end', () => {
59+
const payload = parseBody(Buffer.concat(chunks).toString('utf8'));
60+
if (!payload.endpoint) {
61+
res.writeHead(400, { 'Content-Type': 'application/json' });
62+
res.end(JSON.stringify({ error: 'Missing endpoint' }));
63+
return;
64+
}
65+
void (0, store_1.deleteSubscription)(payload.endpoint).then(() => {
66+
res.writeHead(200, { 'Content-Type': 'application/json' });
67+
res.end(JSON.stringify({ ok: true }));
68+
});
69+
});
70+
return;
71+
}
72+
res.writeHead(404, { 'Content-Type': 'application/json' });
73+
res.end(JSON.stringify({ error: 'Not found' }));
74+
});
75+
server.listen(config.apiPort, () => {
76+
console.log(`[push-notifier] API listening on :${config.apiPort}`);
77+
});
78+
return server;
79+
}

services/assignment-notifier/dist/config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,9 @@ function getNotifierServiceConfig() {
1919
wcifApiBaseUrl: process.env.WCIF_API_BASE_URL ?? 'https://www.worldcubeassociation.org/api/v0',
2020
apiToken: process.env.WCA_OAUTH_TOKEN ?? '',
2121
competitionIds,
22+
apiPort: readNumber('NOTIFIER_API_PORT', 8787),
23+
vapidSubject: process.env.VAPID_SUBJECT ?? 'mailto:notifications@example.com',
24+
vapidPublicKey: process.env.VAPID_PUBLIC_KEY ?? '',
25+
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY ?? '',
2226
};
2327
}

services/assignment-notifier/dist/index.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
'use strict';
22
Object.defineProperty(exports, '__esModule', { value: true });
3-
const diff_1 = require('./diff');
3+
const apiServer_1 = require('./apiServer');
44
const config_1 = require('./config');
5+
const diff_1 = require('./diff');
56
const pushSender_1 = require('./pushSender');
67
const store_1 = require('./store');
78
const wcifClient_1 = require('./wcifClient');
@@ -23,6 +24,7 @@ async function runOnce() {
2324
await (0, pushSender_1.sendPushNotifications)({
2425
jobs,
2526
subscriptions: store.subscriptions,
27+
config,
2628
});
2729
store.snapshots = [
2830
...store.snapshots.filter((snapshot) => snapshot.competitionId !== competitionId),
@@ -34,6 +36,7 @@ async function runOnce() {
3436
}
3537
async function main() {
3638
const config = (0, config_1.getNotifierServiceConfig)();
39+
(0, apiServer_1.startNotifierApiServer)(config);
3740
await runOnce();
3841
if (process.env.NOTIFIER_RUN_ONCE === 'true') {
3942
return;

services/assignment-notifier/dist/pushSender.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,22 @@
22
Object.defineProperty(exports, '__esModule', { value: true });
33
exports.sendPushNotifications = sendPushNotifications;
44
async function sendPushNotifications(params) {
5+
if (!params.config.vapidPublicKey || !params.config.vapidPrivateKey) {
6+
console.warn('[push-notifier] Skipping sends: missing VAPID keys');
7+
return;
8+
}
59
for (const job of params.jobs) {
610
const subscriptions = params.subscriptions.filter(
711
(subscription) => subscription.userId === job.userId,
812
);
913
for (const subscription of subscriptions) {
1014
console.log(
11-
'[push-notifier] send placeholder push',
15+
'[push-notifier] TODO: send web push payload',
1216
JSON.stringify({
13-
userId: subscription.userId,
1417
endpoint: subscription.endpoint,
18+
userId: subscription.userId,
19+
title: job.title,
20+
body: job.body,
1521
competitionId: job.competitionId,
1622
dedupeKey: job.dedupeKey,
1723
}),

services/assignment-notifier/dist/store.js

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ var __importDefault =
77
Object.defineProperty(exports, '__esModule', { value: true });
88
exports.readStore = readStore;
99
exports.saveStore = saveStore;
10+
exports.upsertSubscription = upsertSubscription;
11+
exports.deleteSubscription = deleteSubscription;
1012
const promises_1 = require('fs/promises');
1113
const path_1 = __importDefault(require('path'));
12-
const STORE_PATH = path_1.default.resolve(
13-
process.cwd(),
14-
'services/assignment-notifier/data/store.json',
15-
);
14+
const STORE_DIR = path_1.default.resolve(process.cwd(), 'services/assignment-notifier/data');
15+
const STORE_PATH = path_1.default.resolve(STORE_DIR, 'store.json');
1616
async function ensureStore() {
1717
try {
1818
const content = await (0, promises_1.readFile)(STORE_PATH, 'utf8');
@@ -29,5 +29,38 @@ async function readStore() {
2929
return ensureStore();
3030
}
3131
async function saveStore(store) {
32+
await (0, promises_1.mkdir)(STORE_DIR, { recursive: true });
3233
await (0, promises_1.writeFile)(STORE_PATH, JSON.stringify(store, null, 2), 'utf8');
3334
}
35+
async function upsertSubscription(params) {
36+
const store = await readStore();
37+
const now = new Date().toISOString();
38+
const existing = store.subscriptions.find(
39+
(subscription) => subscription.endpoint === params.endpoint,
40+
);
41+
if (existing) {
42+
existing.userId = params.userId;
43+
existing.p256dh = params.p256dh;
44+
existing.auth = params.auth;
45+
existing.updatedAt = now;
46+
} else {
47+
const next = {
48+
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
49+
userId: params.userId,
50+
endpoint: params.endpoint,
51+
p256dh: params.p256dh,
52+
auth: params.auth,
53+
createdAt: now,
54+
updatedAt: now,
55+
};
56+
store.subscriptions.push(next);
57+
}
58+
await saveStore(store);
59+
}
60+
async function deleteSubscription(endpoint) {
61+
const store = await readStore();
62+
store.subscriptions = store.subscriptions.filter(
63+
(subscription) => subscription.endpoint !== endpoint,
64+
);
65+
await saveStore(store);
66+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import http from 'http';
2+
import type { NotifierServiceConfig } from './config';
3+
import { deleteSubscription, readStore, upsertSubscription } from './store';
4+
5+
interface SubscriptionPayload {
6+
endpoint?: string;
7+
p256dh?: string;
8+
auth?: string;
9+
userId?: number;
10+
}
11+
12+
function parseBody(body: string): SubscriptionPayload {
13+
try {
14+
return JSON.parse(body) as SubscriptionPayload;
15+
} catch {
16+
return {};
17+
}
18+
}
19+
20+
export function startNotifierApiServer(config: NotifierServiceConfig) {
21+
const server = http.createServer((req, res) => {
22+
const url = req.url ?? '';
23+
24+
if (req.method === 'GET' && url === '/health') {
25+
res.writeHead(200, { 'Content-Type': 'application/json' });
26+
res.end(JSON.stringify({ ok: true }));
27+
return;
28+
}
29+
30+
if (req.method === 'GET' && url === '/subscriptions') {
31+
void readStore().then((store) => {
32+
res.writeHead(200, { 'Content-Type': 'application/json' });
33+
res.end(JSON.stringify(store.subscriptions));
34+
});
35+
return;
36+
}
37+
38+
if (req.method === 'POST' && url === '/subscriptions') {
39+
const chunks: Buffer[] = [];
40+
req.on('data', (chunk) => chunks.push(chunk));
41+
req.on('end', () => {
42+
const payload = parseBody(Buffer.concat(chunks).toString('utf8'));
43+
44+
if (!payload.endpoint || !payload.p256dh || !payload.auth || !payload.userId) {
45+
res.writeHead(400, { 'Content-Type': 'application/json' });
46+
res.end(JSON.stringify({ error: 'Missing endpoint, p256dh, auth, or userId' }));
47+
return;
48+
}
49+
50+
void upsertSubscription({
51+
userId: payload.userId,
52+
endpoint: payload.endpoint,
53+
p256dh: payload.p256dh,
54+
auth: payload.auth,
55+
}).then(() => {
56+
res.writeHead(201, { 'Content-Type': 'application/json' });
57+
res.end(JSON.stringify({ ok: true }));
58+
});
59+
});
60+
return;
61+
}
62+
63+
if (req.method === 'DELETE' && url === '/subscriptions') {
64+
const chunks: Buffer[] = [];
65+
req.on('data', (chunk) => chunks.push(chunk));
66+
req.on('end', () => {
67+
const payload = parseBody(Buffer.concat(chunks).toString('utf8'));
68+
if (!payload.endpoint) {
69+
res.writeHead(400, { 'Content-Type': 'application/json' });
70+
res.end(JSON.stringify({ error: 'Missing endpoint' }));
71+
return;
72+
}
73+
74+
void deleteSubscription(payload.endpoint).then(() => {
75+
res.writeHead(200, { 'Content-Type': 'application/json' });
76+
res.end(JSON.stringify({ ok: true }));
77+
});
78+
});
79+
return;
80+
}
81+
82+
res.writeHead(404, { 'Content-Type': 'application/json' });
83+
res.end(JSON.stringify({ error: 'Not found' }));
84+
});
85+
86+
server.listen(config.apiPort, () => {
87+
console.log(`[push-notifier] API listening on :${config.apiPort}`);
88+
});
89+
90+
return server;
91+
}

services/assignment-notifier/src/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ export interface NotifierServiceConfig {
33
wcifApiBaseUrl: string;
44
apiToken: string;
55
competitionIds: string[];
6+
apiPort: number;
7+
vapidSubject: string;
8+
vapidPublicKey: string;
9+
vapidPrivateKey: string;
610
}
711

812
function readNumber(name: string, fallback: number) {
@@ -26,5 +30,9 @@ export function getNotifierServiceConfig(): NotifierServiceConfig {
2630
wcifApiBaseUrl: process.env.WCIF_API_BASE_URL ?? 'https://www.worldcubeassociation.org/api/v0',
2731
apiToken: process.env.WCA_OAUTH_TOKEN ?? '',
2832
competitionIds,
33+
apiPort: readNumber('NOTIFIER_API_PORT', 8787),
34+
vapidSubject: process.env.VAPID_SUBJECT ?? 'mailto:notifications@example.com',
35+
vapidPublicKey: process.env.VAPID_PUBLIC_KEY ?? '',
36+
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY ?? '',
2937
};
3038
}

services/assignment-notifier/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { startNotifierApiServer } from './apiServer';
12
import { getNotifierServiceConfig } from './config';
23
import { buildNotificationJobs, createAssignmentSnapshots } from './diff';
34
import { sendPushNotifications } from './pushSender';
@@ -26,6 +27,7 @@ async function runOnce() {
2627
await sendPushNotifications({
2728
jobs,
2829
subscriptions: store.subscriptions,
30+
config,
2931
});
3032

3133
store.snapshots = [
@@ -40,6 +42,7 @@ async function runOnce() {
4042

4143
async function main() {
4244
const config = getNotifierServiceConfig();
45+
startNotifierApiServer(config);
4346

4447
await runOnce();
4548

0 commit comments

Comments
 (0)