-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpushNotificationService.ts
More file actions
329 lines (299 loc) · 11.7 KB
/
Copy pathpushNotificationService.ts
File metadata and controls
329 lines (299 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
import webpush from 'web-push';
import { pool } from './db.js';
let vapidPublicKey: string = process.env.VAPID_PUBLIC_KEY || '';
let vapidPrivateKey: string = process.env.VAPID_PRIVATE_KEY || '';
const vapidSubject: string = process.env.VAPID_SUBJECT || 'mailto:admin@vsbec.ac.in';
let isPushInitialized = false;
export interface PushPayload {
title: string;
body: string;
icon?: string;
badge?: string;
url?: string;
tag?: string;
data?: Record<string, any>;
}
/**
* Initializes VAPID keys strictly from environment variables or database system_settings.
* If no keys are present in env or DB, generates fresh keys and persists them in DB.
*/
export async function initPushNotifications(): Promise<void> {
if (isPushInitialized) return;
try {
// 1. Check if keys exist in DB if not in process.env
if (!vapidPublicKey || !vapidPrivateKey) {
const pubRes = await pool.query("SELECT value FROM system_settings WHERE key = 'vapid_public_key' LIMIT 1").catch(() => ({ rows: [] }));
const privRes = await pool.query("SELECT value FROM system_settings WHERE key = 'vapid_private_key' LIMIT 1").catch(() => ({ rows: [] }));
if (pubRes.rows[0]?.value && privRes.rows[0]?.value) {
vapidPublicKey = pubRes.rows[0].value;
vapidPrivateKey = privRes.rows[0].value;
} else {
// Auto-generate if not provided in env or DB
const keys = webpush.generateVAPIDKeys();
vapidPublicKey = keys.publicKey;
vapidPrivateKey = keys.privateKey;
await pool.query(`
INSERT INTO system_settings (key, value, updated_at)
VALUES
('vapid_public_key', $1, CURRENT_TIMESTAMP),
('vapid_private_key', $2, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
`, [vapidPublicKey, vapidPrivateKey]).catch(err => {
console.error('[WebPush] Error persisting VAPID keys to DB:', err);
});
console.log('[WebPush] 🔑 Generated and saved persistent VAPID Keypair to database.');
}
} else {
// If keys provided via process.env, update DB system_settings
await pool.query(`
INSERT INTO system_settings (key, value, updated_at)
VALUES
('vapid_public_key', $1, CURRENT_TIMESTAMP),
('vapid_private_key', $2, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
`, [vapidPublicKey, vapidPrivateKey]).catch(() => {});
}
webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey);
isPushInitialized = true;
console.log('[WebPush] ✅ Web Push Service initialized from environment.');
} catch (err: any) {
console.error('[WebPush] Initialization error:', err.message);
}
}
/**
* Returns the public VAPID key needed for client browser push subscription
*/
export function getVapidPublicKey(): string {
return vapidPublicKey;
}
/**
* Saves or updates a browser PushSubscription for a user
*/
export async function savePushSubscription(
userId: string,
subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
userAgent?: string
): Promise<boolean> {
if (!subscription || !subscription.endpoint || !subscription.keys?.p256dh || !subscription.keys?.auth) {
throw new Error('Invalid PushSubscription payload');
}
try {
await pool.query(`
INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, user_agent, updated_at)
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
ON CONFLICT (user_id, endpoint)
DO UPDATE SET
p256dh = EXCLUDED.p256dh,
auth = EXCLUDED.auth,
user_agent = EXCLUDED.user_agent,
updated_at = CURRENT_TIMESTAMP
`, [userId, subscription.endpoint, subscription.keys.p256dh, subscription.keys.auth, userAgent || null]);
console.log(`[WebPush] 📱 Saved push subscription for user ${userId}`);
return true;
} catch (err: any) {
console.error(`[WebPush] Failed to save push subscription for user ${userId}:`, err.message);
throw err;
}
}
/**
* Removes a push subscription for a user (e.g. on logout or permission revoke)
*/
export async function removePushSubscription(userId: string, endpoint: string): Promise<boolean> {
try {
await pool.query('DELETE FROM push_subscriptions WHERE user_id = $1 AND endpoint = $2', [userId, endpoint]);
return true;
} catch (err: any) {
console.error(`[WebPush] Failed to delete push subscription for user ${userId}:`, err.message);
return false;
}
}
/**
* Helper to dispatch a web push payload to a list of subscription records
*/
async function dispatchPushToSubscriptions(subs: any[], payload: PushPayload): Promise<{ sent: number; failed: number }> {
if (!subs || subs.length === 0) return { sent: 0, failed: 0 };
if (!isPushInitialized) await initPushNotifications();
const formattedPayload = JSON.stringify({
title: payload.title || 'VSBEC IT TaskManager',
body: payload.body || '',
icon: payload.icon || '/logo.png',
badge: payload.badge || '/badge.png',
url: payload.url || '/',
tag: payload.tag || 'taskmanager-notification',
timestamp: Date.now(),
data: payload.data || {}
});
let sent = 0;
let failed = 0;
const expiredEndpoints: string[] = [];
await Promise.allSettled(
subs.map(async (sub) => {
const pushConfig = {
endpoint: sub.endpoint,
keys: {
p256dh: sub.p256dh,
auth: sub.auth
}
};
try {
await webpush.sendNotification(pushConfig, formattedPayload, {
urgency: 'high',
TTL: 86400,
topic: 'taskmanager-alerts'
});
sent++;
} catch (err: any) {
failed++;
// 404 Not Found or 410 Gone means the subscription is expired/uninstalled
if (err.statusCode === 404 || err.statusCode === 410) {
expiredEndpoints.push(sub.endpoint);
} else {
console.warn(`[WebPush] Push dispatch failed for endpoint (${err.statusCode || err.message})`);
}
}
})
);
// Clean up dead subscriptions
if (expiredEndpoints.length > 0) {
pool.query('DELETE FROM push_subscriptions WHERE endpoint = ANY($1)', [expiredEndpoints])
.then(() => console.log(`[WebPush] 🧹 Cleaned up ${expiredEndpoints.length} expired push subscription(s).`))
.catch(e => console.error('[WebPush] Error pruning expired subscriptions:', e));
}
return { sent, failed };
}
/**
* Dispatches a push notification to a specific user across all their registered devices/browsers
*/
export async function sendPushToUser(userId: string, payload: PushPayload): Promise<{ sent: number; failed: number }> {
try {
const res = await pool.query('SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = $1', [userId]);
return await dispatchPushToSubscriptions(res.rows, payload);
} catch (err: any) {
console.error(`[WebPush] sendPushToUser error for user ${userId}:`, err.message);
return { sent: 0, failed: 0 };
}
}
/**
* Dispatches a push notification to multiple users
*/
export async function sendPushToUsers(userIds: string[], payload: PushPayload): Promise<{ sent: number; failed: number }> {
if (!userIds || userIds.length === 0) return { sent: 0, failed: 0 };
try {
const res = await pool.query('SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = ANY($1)', [userIds]);
return await dispatchPushToSubscriptions(res.rows, payload);
} catch (err: any) {
console.error('[WebPush] sendPushToUsers error:', err.message);
return { sent: 0, failed: 0 };
}
}
/**
* Dispatches a push notification to all students in a specific class
*/
export async function sendPushToClass(classId: string, payload: PushPayload): Promise<{ sent: number; failed: number }> {
try {
const res = await pool.query(`
SELECT ps.endpoint, ps.p256dh, ps.auth
FROM push_subscriptions ps
JOIN users u ON ps.user_id = u.id
WHERE u.class_id = $1 AND u.role = 'STUDENT'
`, [classId]);
return await dispatchPushToSubscriptions(res.rows, payload);
} catch (err: any) {
console.error(`[WebPush] sendPushToClass error for class ${classId}:`, err.message);
return { sent: 0, failed: 0 };
}
}
/**
* Dispatches a push notification to all students across multiple classes
*/
export async function sendPushToClasses(classIds: string[], payload: PushPayload): Promise<{ sent: number; failed: number }> {
if (!classIds || classIds.length === 0) return { sent: 0, failed: 0 };
try {
const res = await pool.query(`
SELECT ps.endpoint, ps.p256dh, ps.auth
FROM push_subscriptions ps
JOIN users u ON ps.user_id = u.id
WHERE u.class_id = ANY($1) AND u.role = 'STUDENT'
`, [classIds]);
return await dispatchPushToSubscriptions(res.rows, payload);
} catch (err: any) {
console.error('[WebPush] sendPushToClasses error:', err.message);
return { sent: 0, failed: 0 };
}
}
/**
* Dispatches a push notification to users with a specific role (e.g. HOD, SUPREME_ADMIN)
*/
export async function sendPushToRole(role: string, payload: PushPayload, departmentId?: string): Promise<{ sent: number; failed: number }> {
try {
let query = `
SELECT ps.endpoint, ps.p256dh, ps.auth
FROM push_subscriptions ps
JOIN users u ON ps.user_id = u.id
WHERE u.role = $1
`;
const params: any[] = [role];
if (departmentId) {
query += ` AND u.department_id = $2`;
params.push(departmentId);
}
const res = await pool.query(query, params);
return await dispatchPushToSubscriptions(res.rows, payload);
} catch (err: any) {
console.error(`[WebPush] sendPushToRole error for role ${role}:`, err.message);
return { sent: 0, failed: 0 };
}
}
/**
* Dispatches a push notification to Class Advisors of given classes
*/
export async function sendPushToClassAdvisors(classIds: string[], payload: PushPayload): Promise<{ sent: number; failed: number }> {
if (!classIds || classIds.length === 0) return { sent: 0, failed: 0 };
try {
const res = await pool.query(`
SELECT ps.endpoint, ps.p256dh, ps.auth
FROM push_subscriptions ps
JOIN users u ON ps.user_id = u.id
WHERE u.class_id = ANY($1) AND u.role = 'CLASS_ADVISOR'
`, [classIds]);
return await dispatchPushToSubscriptions(res.rows, payload);
} catch (err: any) {
console.error('[WebPush] sendPushToClassAdvisors error:', err.message);
return { sent: 0, failed: 0 };
}
}
/**
* Dispatches a push notification to Student Coordinators of given class
*/
export async function sendPushToCoordinators(classId: string, payload: PushPayload, excludeUserId?: string): Promise<{ sent: number; failed: number }> {
try {
let query = `
SELECT ps.endpoint, ps.p256dh, ps.auth
FROM push_subscriptions ps
JOIN users u ON ps.user_id = u.id
WHERE u.class_id = $1 AND u.role = 'STUDENT' AND u.is_coordinator = TRUE
`;
const params: any[] = [classId];
if (excludeUserId) {
query += ` AND u.id != $2`;
params.push(excludeUserId);
}
const res = await pool.query(query, params);
return await dispatchPushToSubscriptions(res.rows, payload);
} catch (err: any) {
console.error(`[WebPush] sendPushToCoordinators error for class ${classId}:`, err.message);
return { sent: 0, failed: 0 };
}
}
/**
* Dispatches a broadcast push notification to all registered devices in the system
*/
export async function sendPushToAll(payload: PushPayload): Promise<{ sent: number; failed: number }> {
try {
const res = await pool.query('SELECT endpoint, p256dh, auth FROM push_subscriptions');
return await dispatchPushToSubscriptions(res.rows, payload);
} catch (err: any) {
console.error('[WebPush] sendPushToAll error:', err.message);
return { sent: 0, failed: 0 };
}
}