Skip to content

Commit f9f5e05

Browse files
authored
Merge pull request #68 from coder13/codex/integrate-notifycomp-push
[codex] Integrate NotifyComp assignment push
2 parents 321370f + a0eee06 commit f9f5e05

10 files changed

Lines changed: 557 additions & 26 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+
});

src/apolloClient.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ import { getMainDefinition } from '@apollo/client/utilities';
44
import { createClient } from 'graphql-ws';
55

66
const httpLink = createHttpLink({
7-
uri: import.meta.env.VITE_NOTIFYCOMP_API_ORIGIN || 'https://admin.notifycomp.com/graphql',
7+
uri: import.meta.env.VITE_NOTIFYCOMP_API_ORIGIN || 'https://api.notifycomp.com/api/graphql',
88
});
99

1010
const wsLink = new GraphQLWsLink(
1111
createClient({
12-
url: import.meta.env.VITE_NOTIFYCOMP_WS_ORIGIN || 'wss://admin.notifycomp.com/graphql',
12+
url: import.meta.env.VITE_NOTIFYCOMP_WS_ORIGIN || 'wss://api.notifycomp.com/api/graphql',
1313
}),
1414
);
1515

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './useAssignmentNotifications';
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { useCallback, useEffect, useMemo, useState } from 'react';
2+
import {
3+
AssignmentNotificationStatus,
4+
disableAssignmentNotifications,
5+
enableAssignmentNotifications,
6+
getAssignmentNotificationStatus,
7+
isAssignmentNotificationsEnabled,
8+
} from '@/lib/notifications/assignmentNotifications';
9+
10+
interface UseAssignmentNotificationsParams {
11+
competitions: ApiCompetition[];
12+
user: User | null;
13+
}
14+
15+
export function useAssignmentNotifications({
16+
competitions,
17+
user,
18+
}: UseAssignmentNotificationsParams) {
19+
const [status, setStatus] = useState<AssignmentNotificationStatus>(
20+
getAssignmentNotificationStatus,
21+
);
22+
const [isSaving, setIsSaving] = useState(false);
23+
const [error, setError] = useState<string | null>(null);
24+
const [isEnabled, setIsEnabled] = useState(isAssignmentNotificationsEnabled);
25+
26+
useEffect(() => {
27+
setStatus(getAssignmentNotificationStatus());
28+
setIsEnabled(isAssignmentNotificationsEnabled());
29+
}, [user]);
30+
31+
const watches = useMemo(
32+
() =>
33+
user
34+
? competitions.map((competition) => ({
35+
competitionId: competition.id,
36+
wcaUserId: user.id,
37+
}))
38+
: [],
39+
[competitions, user],
40+
);
41+
42+
const enable = useCallback(async () => {
43+
setIsSaving(true);
44+
setError(null);
45+
46+
try {
47+
const nextStatus = await enableAssignmentNotifications(watches);
48+
setStatus(nextStatus);
49+
setIsEnabled(isAssignmentNotificationsEnabled());
50+
} catch (e) {
51+
setError(e instanceof Error ? e.message : 'Unable to enable assignment notifications.');
52+
setStatus(getAssignmentNotificationStatus());
53+
setIsEnabled(isAssignmentNotificationsEnabled());
54+
} finally {
55+
setIsSaving(false);
56+
}
57+
}, [watches]);
58+
59+
const disable = useCallback(async () => {
60+
setIsSaving(true);
61+
setError(null);
62+
63+
try {
64+
await disableAssignmentNotifications();
65+
setStatus(getAssignmentNotificationStatus());
66+
setIsEnabled(isAssignmentNotificationsEnabled());
67+
} catch (e) {
68+
setError(e instanceof Error ? e.message : 'Unable to disable assignment notifications.');
69+
setIsEnabled(isAssignmentNotificationsEnabled());
70+
} finally {
71+
setIsSaving(false);
72+
}
73+
}, []);
74+
75+
return {
76+
canEnable: (status === 'default' || status === 'granted') && !isEnabled && watches.length > 0,
77+
canDisable: status === 'granted' && isEnabled,
78+
enable,
79+
disable,
80+
error,
81+
isSaving,
82+
status,
83+
watchCount: watches.length,
84+
};
85+
}

0 commit comments

Comments
 (0)