Skip to content

Commit 6d78f11

Browse files
committed
Wire frontend push subscription to notifier service
1 parent d4d4164 commit 6d78f11

24 files changed

Lines changed: 753 additions & 24 deletions

File tree

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
"lint": "eslint ./src/",
1212
"check:type": "tsc --noEmit",
1313
"serve": "vite preview",
14-
"prepare": "husky"
14+
"prepare": "husky",
15+
"notify:service": "yarn notify:service:build && node services/assignment-notifier/dist/index.js",
16+
"notify:service:build": "tsc -p services/assignment-notifier/tsconfig.json"
1517
},
1618
"dependencies": {
1719
"@apollo/client": "^3.8.7",
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Assignment Notifier Service (scaffold)
2+
3+
This is a single-service scaffold that polls WCIFs, detects assignment changes, and dispatches placeholder push notifications.
4+
5+
## What it includes
6+
7+
- WCIF polling loop
8+
- Assignment hash snapshots and diffing
9+
- Notification job generation with dedupe keys
10+
- Local JSON store for snapshots/subscriptions
11+
- Placeholder push sender (logs output)
12+
13+
## Configure
14+
15+
Create environment variables:
16+
17+
- `WCA_OAUTH_TOKEN` - WCA token with WCIF access
18+
- `WCIF_COMPETITION_IDS` - comma-separated competition IDs
19+
- `WCIF_API_BASE_URL` (optional, defaults to WCA v0 API)
20+
- `WCIF_POLL_INTERVAL_MS` (optional, defaults to 300000)
21+
22+
## Run
23+
24+
```bash
25+
yarn notify:service
26+
```
27+
28+
## Next steps
29+
30+
- Replace `sendPushNotifications` with actual Web Push delivery.
31+
- Add API routes to register/remove subscriptions.
32+
- Move store to a persistent DB.
33+
- Add upcoming-assignment reminder jobs.

services/assignment-notifier/data/.gitkeep

Whitespace-only changes.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"snapshots": [],
3+
"subscriptions": [],
4+
"deliveredDedupeKeys": []
5+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
'use strict';
2+
Object.defineProperty(exports, '__esModule', { value: true });
3+
exports.getNotifierServiceConfig = getNotifierServiceConfig;
4+
function readNumber(name, fallback) {
5+
const raw = process.env[name];
6+
if (!raw) {
7+
return fallback;
8+
}
9+
const parsed = Number(raw);
10+
return Number.isFinite(parsed) ? parsed : fallback;
11+
}
12+
function getNotifierServiceConfig() {
13+
const competitionIds = (process.env.WCIF_COMPETITION_IDS ?? '')
14+
.split(',')
15+
.map((part) => part.trim())
16+
.filter(Boolean);
17+
return {
18+
wcifPollIntervalMs: readNumber('WCIF_POLL_INTERVAL_MS', 5 * 60 * 1000),
19+
wcifApiBaseUrl: process.env.WCIF_API_BASE_URL ?? 'https://www.worldcubeassociation.org/api/v0',
20+
apiToken: process.env.WCA_OAUTH_TOKEN ?? '',
21+
competitionIds,
22+
};
23+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
'use strict';
2+
Object.defineProperty(exports, '__esModule', { value: true });
3+
exports.createAssignmentSnapshots = createAssignmentSnapshots;
4+
exports.buildNotificationJobs = buildNotificationJobs;
5+
const crypto_1 = require('crypto');
6+
function hashAssignments(value) {
7+
return (0, crypto_1.createHash)('sha256')
8+
.update(JSON.stringify(value ?? null))
9+
.digest('hex');
10+
}
11+
function createAssignmentSnapshots(wcif) {
12+
return wcif.persons
13+
.filter((person) => person.wcaUserId)
14+
.map((person) => ({
15+
competitionId: wcif.id,
16+
personWcaUserId: person.wcaUserId,
17+
assignmentsHash: hashAssignments(person.assignments),
18+
fetchedAt: new Date().toISOString(),
19+
}));
20+
}
21+
function buildNotificationJobs(params) {
22+
const previousByUser = new Map(
23+
params.previousSnapshots.map((snapshot) => [
24+
`${snapshot.competitionId}:${snapshot.personWcaUserId}`,
25+
snapshot,
26+
]),
27+
);
28+
return params.nextSnapshots.flatMap((snapshot) => {
29+
const id = `${snapshot.competitionId}:${snapshot.personWcaUserId}`;
30+
const previous = previousByUser.get(id);
31+
if (!previous || previous.assignmentsHash === snapshot.assignmentsHash) {
32+
return [];
33+
}
34+
const dedupeKey = `assignment-update:${id}:${snapshot.assignmentsHash}`;
35+
return [
36+
{
37+
id: `${Date.now()}:${id}`,
38+
userId: snapshot.personWcaUserId,
39+
competitionId: snapshot.competitionId,
40+
title: 'New assignment update',
41+
body: 'Your assignments were updated. Open the app to review the latest groups.',
42+
dedupeKey,
43+
createdAt: new Date().toISOString(),
44+
},
45+
];
46+
});
47+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
'use strict';
2+
Object.defineProperty(exports, '__esModule', { value: true });
3+
const diff_1 = require('./diff');
4+
const config_1 = require('./config');
5+
const pushSender_1 = require('./pushSender');
6+
const store_1 = require('./store');
7+
const wcifClient_1 = require('./wcifClient');
8+
async function runOnce() {
9+
const config = (0, config_1.getNotifierServiceConfig)();
10+
const store = await (0, store_1.readStore)();
11+
for (const competitionId of config.competitionIds) {
12+
const wcif = await (0, wcifClient_1.fetchWcif)(competitionId, {
13+
apiBaseUrl: config.wcifApiBaseUrl,
14+
token: config.apiToken,
15+
});
16+
const nextSnapshots = (0, diff_1.createAssignmentSnapshots)(wcif);
17+
const previousSnapshots = store.snapshots.filter(
18+
(snapshot) => snapshot.competitionId === competitionId,
19+
);
20+
const jobs = (0, diff_1.buildNotificationJobs)({ previousSnapshots, nextSnapshots }).filter(
21+
(job) => !store.deliveredDedupeKeys.includes(job.dedupeKey),
22+
);
23+
await (0, pushSender_1.sendPushNotifications)({
24+
jobs,
25+
subscriptions: store.subscriptions,
26+
});
27+
store.snapshots = [
28+
...store.snapshots.filter((snapshot) => snapshot.competitionId !== competitionId),
29+
...nextSnapshots,
30+
];
31+
store.deliveredDedupeKeys.push(...jobs.map((job) => job.dedupeKey));
32+
}
33+
await (0, store_1.saveStore)(store);
34+
}
35+
async function main() {
36+
const config = (0, config_1.getNotifierServiceConfig)();
37+
await runOnce();
38+
if (process.env.NOTIFIER_RUN_ONCE === 'true') {
39+
return;
40+
}
41+
setInterval(() => {
42+
void runOnce();
43+
}, config.wcifPollIntervalMs);
44+
}
45+
void main();
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
'use strict';
2+
Object.defineProperty(exports, '__esModule', { value: true });
3+
exports.sendPushNotifications = sendPushNotifications;
4+
async function sendPushNotifications(params) {
5+
for (const job of params.jobs) {
6+
const subscriptions = params.subscriptions.filter(
7+
(subscription) => subscription.userId === job.userId,
8+
);
9+
for (const subscription of subscriptions) {
10+
console.log(
11+
'[push-notifier] send placeholder push',
12+
JSON.stringify({
13+
userId: subscription.userId,
14+
endpoint: subscription.endpoint,
15+
competitionId: job.competitionId,
16+
dedupeKey: job.dedupeKey,
17+
}),
18+
);
19+
}
20+
}
21+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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.readStore = readStore;
9+
exports.saveStore = saveStore;
10+
const promises_1 = require('fs/promises');
11+
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+
);
16+
async function ensureStore() {
17+
try {
18+
const content = await (0, promises_1.readFile)(STORE_PATH, 'utf8');
19+
return JSON.parse(content);
20+
} catch {
21+
return {
22+
snapshots: [],
23+
subscriptions: [],
24+
deliveredDedupeKeys: [],
25+
};
26+
}
27+
}
28+
async function readStore() {
29+
return ensureStore();
30+
}
31+
async function saveStore(store) {
32+
await (0, promises_1.writeFile)(STORE_PATH, JSON.stringify(store, null, 2), 'utf8');
33+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
'use strict';
2+
Object.defineProperty(exports, '__esModule', { value: true });

0 commit comments

Comments
 (0)