Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

# production
/dist
/services/assignment-notifier/dist
/services/assignment-notifier/data/store.json

# vite pwa dev
/dev-dist
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions services/assignment-notifier/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Assignment Notifier Service (scaffold)

This is a single-service scaffold that polls WCIFs, detects assignment changes, and exposes subscription APIs for 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
- Subscription API server (`/health`, `/subscriptions`)
- Placeholder push sender (ready for Web Push implementation)

## 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)
- `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

```bash
yarn notify:service
```

## Next steps

- Replace `sendPushNotifications` with actual Web Push delivery.
- Move store to a persistent DB.
- Add upcoming-assignment reminder jobs.
- Add auth middleware for subscription API routes.
Empty file.
91 changes: 91 additions & 0 deletions services/assignment-notifier/src/apiServer.ts
Original file line number Diff line number Diff line change
@@ -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;
}
38 changes: 38 additions & 0 deletions services/assignment-notifier/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
export interface NotifierServiceConfig {
wcifPollIntervalMs: number;
wcifApiBaseUrl: string;
apiToken: string;
competitionIds: string[];
apiPort: number;
vapidSubject: string;
vapidPublicKey: string;
vapidPrivateKey: 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,
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 ?? '',
};
}
54 changes: 54 additions & 0 deletions services/assignment-notifier/src/diff.ts
Original file line number Diff line number Diff line change
@@ -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(),
},
];
});
}
58 changes: 58 additions & 0 deletions services/assignment-notifier/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { startNotifierApiServer } from './apiServer';
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,
config,
});

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();
startNotifierApiServer(config);

await runOnce();

if (process.env.NOTIFIER_RUN_ONCE === 'true') {
return;
}

setInterval(() => {
void runOnce();
}, config.wcifPollIntervalMs);
}

void main();
33 changes: 33 additions & 0 deletions services/assignment-notifier/src/pushSender.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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,
);

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,
}),
);
}
}
}
Loading
Loading