Skip to content

Commit 0d51312

Browse files
committed
integrate notifycomp assignment push
1 parent 321370f commit 0d51312

9 files changed

Lines changed: 501 additions & 24 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
const crypto = require('crypto');
2+
3+
const headers = {
4+
'Access-Control-Allow-Headers': 'Content-Type',
5+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
6+
'Access-Control-Allow-Origin': '*',
7+
'Content-Type': 'application/json',
8+
};
9+
10+
const base64Url = (value) => Buffer.from(value).toString('base64url');
11+
12+
const signJwt = (claims, secret) => {
13+
const encodedHeader = base64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
14+
const encodedPayload = base64Url(JSON.stringify(claims));
15+
const signature = crypto
16+
.createHmac('sha256', secret)
17+
.update(`${encodedHeader}.${encodedPayload}`)
18+
.digest('base64url');
19+
20+
return `${encodedHeader}.${encodedPayload}.${signature}`;
21+
};
22+
23+
exports.handler = async (event) => {
24+
if (event.httpMethod === 'OPTIONS') {
25+
return { statusCode: 204, headers };
26+
}
27+
28+
if (event.httpMethod !== 'POST') {
29+
return {
30+
statusCode: 405,
31+
headers,
32+
body: JSON.stringify({ message: 'Method not allowed' }),
33+
};
34+
}
35+
36+
const secret = process.env.COMPETITION_GROUPS_JWT_SECRET;
37+
if (!secret) {
38+
return {
39+
statusCode: 500,
40+
headers,
41+
body: JSON.stringify({ message: 'Notification token secret is not configured' }),
42+
};
43+
}
44+
45+
const { accessToken } = JSON.parse(event.body || '{}');
46+
if (!accessToken) {
47+
return {
48+
statusCode: 400,
49+
headers,
50+
body: JSON.stringify({ message: 'Missing WCA access token' }),
51+
};
52+
}
53+
54+
const wcaOrigin = process.env.WCA_ORIGIN || 'https://www.worldcubeassociation.org';
55+
const meResponse = await fetch(`${wcaOrigin}/api/v0/me`, {
56+
headers: {
57+
Authorization: `Bearer ${accessToken}`,
58+
},
59+
});
60+
61+
if (!meResponse.ok) {
62+
return {
63+
statusCode: 401,
64+
headers,
65+
body: JSON.stringify({ message: 'Invalid WCA access token' }),
66+
};
67+
}
68+
69+
const { me } = await meResponse.json();
70+
if (!me?.id) {
71+
return {
72+
statusCode: 401,
73+
headers,
74+
body: JSON.stringify({ message: 'Unable to resolve WCA user' }),
75+
};
76+
}
77+
78+
const now = Math.floor(Date.now() / 1000);
79+
const token = signJwt(
80+
{
81+
aud: process.env.COMPETITION_GROUPS_JWT_AUDIENCE || 'notifycomp',
82+
exp: now + 10 * 60,
83+
iat: now,
84+
iss: process.env.COMPETITION_GROUPS_JWT_ISSUER || 'competitiongroups.com',
85+
sub: `wca:${me.id}`,
86+
wcaUserIds: [me.id],
87+
},
88+
secret,
89+
);
90+
91+
return {
92+
statusCode: 200,
93+
headers,
94+
body: JSON.stringify({ token }),
95+
};
96+
};

public/notification-sw.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
self.addEventListener('push', (event) => {
2+
if (!event.data) {
3+
return;
4+
}
5+
6+
const payload = event.data.json();
7+
const title = payload.title || 'Assignment update';
8+
const options = {
9+
body: payload.body,
10+
data: payload,
11+
tag: payload.dedupeKey || 'assignment-change',
12+
};
13+
14+
event.waitUntil(self.registration.showNotification(title, options));
15+
});
16+
17+
self.addEventListener('notificationclick', (event) => {
18+
event.notification.close();
19+
20+
const targetUrl = event.notification.data?.url || '/settings';
21+
const url = new URL(targetUrl, self.location.origin).href;
22+
23+
event.waitUntil(
24+
self.clients.matchAll({ includeUncontrolled: true, type: 'window' }).then((clients) => {
25+
const existingClient = clients.find((client) => client.url === url);
26+
27+
if (existingClient) {
28+
return existingClient.focus();
29+
}
30+
31+
return self.clients.openWindow(url);
32+
}),
33+
);
34+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './useAssignmentNotifications';
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { useCallback, useMemo, useState } from 'react';
2+
import {
3+
AssignmentNotificationStatus,
4+
disableAssignmentNotifications,
5+
enableAssignmentNotifications,
6+
getAssignmentNotificationStatus,
7+
} from '@/lib/notifications/assignmentNotifications';
8+
9+
interface UseAssignmentNotificationsParams {
10+
competitions: ApiCompetition[];
11+
user: User | null;
12+
}
13+
14+
export function useAssignmentNotifications({
15+
competitions,
16+
user,
17+
}: UseAssignmentNotificationsParams) {
18+
const [status, setStatus] = useState<AssignmentNotificationStatus>(
19+
getAssignmentNotificationStatus,
20+
);
21+
const [isSaving, setIsSaving] = useState(false);
22+
const [error, setError] = useState<string | null>(null);
23+
24+
const watches = useMemo(
25+
() =>
26+
user
27+
? competitions.map((competition) => ({
28+
competitionId: competition.id,
29+
wcaUserId: user.id,
30+
}))
31+
: [],
32+
[competitions, user],
33+
);
34+
35+
const enable = useCallback(async () => {
36+
setIsSaving(true);
37+
setError(null);
38+
39+
try {
40+
const nextStatus = await enableAssignmentNotifications(watches);
41+
setStatus(nextStatus);
42+
} catch (e) {
43+
setError(e instanceof Error ? e.message : 'Unable to enable assignment notifications.');
44+
setStatus(getAssignmentNotificationStatus());
45+
} finally {
46+
setIsSaving(false);
47+
}
48+
}, [watches]);
49+
50+
const disable = useCallback(async () => {
51+
setIsSaving(true);
52+
setError(null);
53+
54+
try {
55+
await disableAssignmentNotifications();
56+
setStatus(getAssignmentNotificationStatus());
57+
} catch (e) {
58+
setError(e instanceof Error ? e.message : 'Unable to disable assignment notifications.');
59+
} finally {
60+
setIsSaving(false);
61+
}
62+
}, []);
63+
64+
return {
65+
canEnable: status === 'default' && watches.length > 0,
66+
canDisable: status === 'granted',
67+
enable,
68+
disable,
69+
error,
70+
isSaving,
71+
status,
72+
watchCount: watches.length,
73+
};
74+
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { getLocalStorage } from '@/lib/localStorage';
2+
3+
const NOTIFY_COMP_ORIGIN = import.meta.env.VITE_NOTIFY_COMP_ORIGIN ?? '';
4+
const NOTIFY_COMP_TOKEN_URL = '/.netlify/functions/notify-comp-token';
5+
6+
interface PushSubscriptionJson {
7+
endpoint?: string;
8+
keys?: {
9+
p256dh?: string;
10+
auth?: string;
11+
};
12+
}
13+
14+
export interface AssignmentNotificationWatch {
15+
competitionId: string;
16+
wcaUserId: number;
17+
}
18+
19+
export type AssignmentNotificationStatus = NotificationPermission | 'not-signed-in' | 'unsupported';
20+
21+
const notifyCompUrl = (path: string) => `${NOTIFY_COMP_ORIGIN}${path}`;
22+
23+
const getAccessToken = () => {
24+
const expiresAt = Number(getLocalStorage('expirationTime') ?? 0);
25+
const accessToken = getLocalStorage('accessToken');
26+
27+
if (!accessToken || !expiresAt || expiresAt <= Date.now()) {
28+
return null;
29+
}
30+
31+
return accessToken;
32+
};
33+
34+
const toUint8Array = (base64: string) => {
35+
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
36+
const normalized = `${base64}${padding}`.replace(/-/g, '+').replace(/_/g, '/');
37+
const raw = window.atob(normalized);
38+
const output = new Uint8Array(raw.length);
39+
40+
for (let index = 0; index < raw.length; index += 1) {
41+
output[index] = raw.charCodeAt(index);
42+
}
43+
44+
return output;
45+
};
46+
47+
export const getAssignmentNotificationStatus = (): AssignmentNotificationStatus => {
48+
if (
49+
!('Notification' in window) ||
50+
!('serviceWorker' in navigator) ||
51+
!('PushManager' in window)
52+
) {
53+
return 'unsupported';
54+
}
55+
56+
if (!getAccessToken()) {
57+
return 'not-signed-in';
58+
}
59+
60+
return Notification.permission;
61+
};
62+
63+
export const requestAssignmentNotificationPermission = async () => {
64+
if (!('Notification' in window)) {
65+
return 'unsupported' as const;
66+
}
67+
68+
if (Notification.permission === 'granted') {
69+
return 'granted' as const;
70+
}
71+
72+
return await Notification.requestPermission();
73+
};
74+
75+
const fetchNotifyCompToken = async () => {
76+
const accessToken = getAccessToken();
77+
if (!accessToken) {
78+
throw new Error('Sign in with WCA to enable assignment notifications.');
79+
}
80+
81+
const response = await fetch(NOTIFY_COMP_TOKEN_URL, {
82+
method: 'POST',
83+
headers: {
84+
'Content-Type': 'application/json',
85+
},
86+
body: JSON.stringify({ accessToken }),
87+
});
88+
89+
if (!response.ok) {
90+
throw new Error(await response.text());
91+
}
92+
93+
const payload = (await response.json()) as { token?: string };
94+
if (!payload.token) {
95+
throw new Error('Notification token response was missing a token.');
96+
}
97+
98+
return payload.token;
99+
};
100+
101+
const fetchVapidPublicKey = async () => {
102+
const response = await fetch(notifyCompUrl('/v0/external/push/vapid-public-key'));
103+
104+
if (!response.ok) {
105+
throw new Error(await response.text());
106+
}
107+
108+
const payload = (await response.json()) as { publicKey?: string };
109+
if (!payload.publicKey) {
110+
throw new Error('NotifyComp did not return a VAPID public key.');
111+
}
112+
113+
return payload.publicKey;
114+
};
115+
116+
const getPushSubscription = async () => {
117+
const registration = await navigator.serviceWorker.ready;
118+
const existing = await registration.pushManager.getSubscription();
119+
120+
if (existing) {
121+
return existing;
122+
}
123+
124+
return await registration.pushManager.subscribe({
125+
userVisibleOnly: true,
126+
applicationServerKey: toUint8Array(await fetchVapidPublicKey()),
127+
});
128+
};
129+
130+
export const enableAssignmentNotifications = async (watches: AssignmentNotificationWatch[]) => {
131+
const permission = await requestAssignmentNotificationPermission();
132+
if (permission !== 'granted') {
133+
return permission;
134+
}
135+
136+
const [token, subscription] = await Promise.all([fetchNotifyCompToken(), getPushSubscription()]);
137+
const payload = subscription.toJSON() as PushSubscriptionJson;
138+
139+
if (!payload.endpoint || !payload.keys?.p256dh || !payload.keys.auth) {
140+
throw new Error('Browser push subscription is missing required keys.');
141+
}
142+
143+
const response = await fetch(notifyCompUrl('/v0/external/push/subscriptions'), {
144+
method: 'POST',
145+
headers: {
146+
Authorization: `Bearer ${token}`,
147+
'Content-Type': 'application/json',
148+
},
149+
body: JSON.stringify({
150+
endpoint: payload.endpoint,
151+
p256dh: payload.keys.p256dh,
152+
auth: payload.keys.auth,
153+
watches,
154+
}),
155+
});
156+
157+
if (!response.ok) {
158+
throw new Error(await response.text());
159+
}
160+
161+
return permission;
162+
};
163+
164+
export const disableAssignmentNotifications = async () => {
165+
const registration = await navigator.serviceWorker.ready;
166+
const subscription = await registration.pushManager.getSubscription();
167+
168+
if (!subscription) {
169+
return;
170+
}
171+
172+
const token = await fetchNotifyCompToken();
173+
const payload = subscription.toJSON() as PushSubscriptionJson;
174+
175+
if (payload.endpoint) {
176+
await fetch(notifyCompUrl('/v0/external/push/subscriptions'), {
177+
method: 'DELETE',
178+
headers: {
179+
Authorization: `Bearer ${token}`,
180+
'Content-Type': 'application/json',
181+
},
182+
body: JSON.stringify({ endpoint: payload.endpoint }),
183+
});
184+
}
185+
186+
await subscription.unsubscribe();
187+
};

0 commit comments

Comments
 (0)