Skip to content

Commit ca237f8

Browse files
authored
Merge pull request #1089 from bigdeen02/solver/issue-843-78-a0
fix: [FEATURE] Add browser push notification opt-in for bounty status changes
2 parents ab29c14 + d305ec1 commit ca237f8

2 files changed

Lines changed: 158 additions & 0 deletions

File tree

backend/src/app.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ import {
3535
} from './services/bountyStore';
3636

3737
import { listOpenIssues } from './services/openIssues';
38+
import {
39+
registerPushSubscription,
40+
unregisterPushSubscription,
41+
} from './services/pushSubscriptionService';
3842

3943
import {
4044
bountyIdSchema,
@@ -1164,6 +1168,65 @@ app.get(
11641168
},
11651169
);
11661170

1171+
// ─── Browser push notification preferences ─────────────────────────────────
1172+
1173+
app.post(
1174+
'/api/notification-preferences/push',
1175+
mutationLimiter,
1176+
requireJsonContentType,
1177+
(req: Request, res: Response) => {
1178+
try {
1179+
const { endpoint, keys } = req.body ?? {};
1180+
1181+
if (typeof endpoint !== 'string' || !endpoint.startsWith('https://')) {
1182+
jsonError(res, req, 400, 'endpoint must be a valid https URL.');
1183+
return;
1184+
}
1185+
1186+
if (
1187+
!keys ||
1188+
typeof keys !== 'object' ||
1189+
typeof keys.p256dh !== 'string' ||
1190+
typeof keys.auth !== 'string'
1191+
) {
1192+
jsonError(res, req, 400, 'keys.p256dh and keys.auth are required.');
1193+
return;
1194+
}
1195+
1196+
const subscription = registerPushSubscription({
1197+
endpoint,
1198+
keys: { p256dh: keys.p256dh, auth: keys.auth },
1199+
createdAt: Date.now(),
1200+
});
1201+
1202+
res.status(201).json({ data: subscription });
1203+
} catch (error) {
1204+
sendError(res, req, error);
1205+
}
1206+
}
1207+
);
1208+
1209+
app.delete(
1210+
'/api/notification-preferences/push',
1211+
mutationLimiter,
1212+
requireJsonContentType,
1213+
(req: Request, res: Response) => {
1214+
try {
1215+
const { endpoint } = req.body ?? {};
1216+
1217+
if (typeof endpoint !== 'string' || !endpoint.startsWith('https://')) {
1218+
jsonError(res, req, 400, 'endpoint must be a valid https URL.');
1219+
return;
1220+
}
1221+
1222+
const removed = unregisterPushSubscription(endpoint);
1223+
res.json({ data: { removed } });
1224+
} catch (error) {
1225+
sendError(res, req, error);
1226+
}
1227+
}
1228+
);
1229+
11671230
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
11681231
if ((err as any).type === 'entity.too.large') {
11691232
res.status(413).json({ error: 'Payload too large', maxBytes: 32768 });
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* pushSubscriptionService.ts – JSON-backed persistence for browser push
3+
* notification subscriptions.
4+
*
5+
* Subscriptions are keyed by their endpoint URL so that re-subscribing with
6+
* the same endpoint is idempotent. The store is a simple JSON file mirroring
7+
* the pattern used by store.ts for bounties.
8+
*/
9+
10+
import fs from "fs";
11+
import path from "path";
12+
13+
export interface PushSubscription {
14+
endpoint: string;
15+
keys: {
16+
p256dh: string;
17+
auth: string;
18+
};
19+
createdAt: number;
20+
}
21+
22+
export function resolvePushStorePath(): string {
23+
return (
24+
process.env.PUSH_SUBSCRIPTION_STORE_PATH ??
25+
path.join(__dirname, "../data/push-subscriptions.json")
26+
);
27+
}
28+
29+
function tryParse<T>(filePath: string): T | null {
30+
try {
31+
const raw = fs.readFileSync(filePath, "utf8");
32+
return JSON.parse(raw) as T;
33+
} catch {
34+
return null;
35+
}
36+
}
37+
38+
function loadSubscriptions(storePath?: string): PushSubscription[] {
39+
const store = storePath ?? resolvePushStorePath();
40+
const primary = tryParse<PushSubscription[]>(store);
41+
if (primary !== null) return primary;
42+
return [];
43+
}
44+
45+
function saveSubscriptions(subscriptions: PushSubscription[], storePath?: string): void {
46+
const store = storePath ?? resolvePushStorePath();
47+
fs.mkdirSync(path.dirname(store), { recursive: true });
48+
fs.writeFileSync(store, JSON.stringify(subscriptions, null, 2), "utf8");
49+
}
50+
51+
/**
52+
* Register (or update) a push subscription. Returns the stored subscription.
53+
*/
54+
export function registerPushSubscription(
55+
subscription: PushSubscription,
56+
storePath?: string,
57+
): PushSubscription {
58+
const subscriptions = loadSubscriptions(storePath);
59+
const existing = subscriptions.find((s) => s.endpoint === subscription.endpoint);
60+
61+
if (existing) {
62+
existing.keys = subscription.keys;
63+
return existing;
64+
}
65+
66+
const stored: PushSubscription = {
67+
endpoint: subscription.endpoint,
68+
keys: subscription.keys,
69+
createdAt: subscription.createdAt ?? Date.now(),
70+
};
71+
subscriptions.push(stored);
72+
saveSubscriptions(subscriptions, storePath);
73+
return stored;
74+
}
75+
76+
/**
77+
* Remove a push subscription by endpoint. Returns true if one was removed.
78+
*/
79+
export function unregisterPushSubscription(
80+
endpoint: string,
81+
storePath?: string,
82+
): boolean {
83+
const subscriptions = loadSubscriptions(storePath);
84+
const next = subscriptions.filter((s) => s.endpoint !== endpoint);
85+
if (next.length === subscriptions.length) return false;
86+
saveSubscriptions(next, storePath);
87+
return true;
88+
}
89+
90+
/**
91+
* List all registered push subscriptions.
92+
*/
93+
export function listPushSubscriptions(storePath?: string): PushSubscription[] {
94+
return loadSubscriptions(storePath);
95+
}

0 commit comments

Comments
 (0)