forked from StellarSplit/StellarSplit
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpush-notifications.processor.ts
More file actions
166 lines (142 loc) · 5.54 KB
/
Copy pathpush-notifications.processor.ts
File metadata and controls
166 lines (142 loc) · 5.54 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
import { Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common';
import { Job } from 'bull';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import * as firebaseAdmin from 'firebase-admin';
import { DeviceRegistration } from './entities/device-registration.entity';
import { NotificationPreference, NotificationEventType } from './entities/notification-preference.entity';
@Processor('push_queue')
export class PushNotificationProcessor {
private readonly logger = new Logger(PushNotificationProcessor.name);
private firebaseApp: firebaseAdmin.app.App | undefined;
constructor(
@InjectRepository(DeviceRegistration)
private deviceRepo: Repository<DeviceRegistration>,
@InjectRepository(NotificationPreference)
private prefRepo: Repository<NotificationPreference>,
private configService: ConfigService,
) {
this.initializeFirebase();
}
private initializeFirebase() {
const serviceAccount = this.configService.get('FIREBASE_SERVICE_ACCOUNT');
if (serviceAccount) {
try {
// Check if already initialized to avoid error
if (!firebaseAdmin.apps.length) {
this.firebaseApp = firebaseAdmin.initializeApp({
credential: firebaseAdmin.credential.cert(JSON.parse(serviceAccount)),
});
} else {
this.firebaseApp = firebaseAdmin.app();
}
} catch (error) {
this.logger.error('Failed to initialize Firebase Admin', error);
}
} else {
this.logger.warn('Firebase service account not provided. Push notifications will not work.');
}
}
@Process('sendPush')
async handleSendPush(job: Job<{ userId: string; eventType: NotificationEventType; title: string; body: string; data?: Record<string, string> }>) {
const { userId, eventType, title, body, data } = job.data;
this.logger.debug(`Processing push notification for user ${userId} event ${eventType}`);
// 1. Check preferences
const pref = await this.prefRepo.findOne({
where: { userId, eventType },
});
if (pref && !pref.pushEnabled) {
this.logger.log(`Push disabled for user ${userId} event ${eventType}`);
return;
}
// 2. Check quiet hours
if (pref && this.isQuietHours(pref)) {
this.logger.log(`Quiet hours active for user ${userId}`);
return;
}
// 3. Get devices
const devices = await this.deviceRepo.find({
where: { userId, isActive: true },
});
if (devices.length === 0) {
this.logger.debug(`No active devices for user ${userId}`);
return;
}
const tokens = devices.map((d) => d.deviceToken);
// 4. Send via FCM
if (!this.firebaseApp) {
this.logger.warn('Firebase not initialized. Skipping notification send.');
return;
}
const message: firebaseAdmin.messaging.MulticastMessage = {
tokens,
notification: {
title,
body,
},
data,
android: {
priority: 'high',
},
apns: {
payload: {
aps: {
sound: 'default',
},
},
},
};
try {
const response = await this.firebaseApp.messaging().sendEachForMulticast(message);
// Handle failed tokens
if (response.failureCount > 0) {
const failedTokens: string[] = [];
response.responses.forEach((resp, idx) => {
if (!resp.success) {
const error = resp.error;
if (error && (error.code === 'messaging/invalid-registration-token' ||
error.code === 'messaging/registration-token-not-registered')) {
failedTokens.push(tokens[idx]);
}
// If error is internal or retryable, we could throw to let Bull retry the job.
// But since sendEachForMulticast is partial success, retrying the whole job might resend to successful tokens.
// Ideally we should create new job for failed tokens, but that's complex.
// For now, we accept partial success.
}
});
if (failedTokens.length > 0) {
await this.handleFailedTokens(failedTokens);
}
}
this.logger.log(`Sent push notification to ${response.successCount}/${tokens.length} devices for user ${userId}`);
} catch (error) {
this.logger.error('Error sending multicast message', error);
throw error; // Let Bull retry
}
}
private isQuietHours(pref: NotificationPreference): boolean {
if (!pref.quietHoursStart || !pref.quietHoursEnd) return false;
const now = new Date();
const userTime = pref.timezone
? new Date(now.toLocaleString('en-US', { timeZone: pref.timezone }))
: now;
const currentHour = userTime.getHours();
const currentMinute = userTime.getMinutes();
const currentTimeVal = currentHour * 60 + currentMinute;
const [startH, startM] = pref.quietHoursStart.split(':').map(Number);
const startVal = startH * 60 + startM;
const [endH, endM] = pref.quietHoursEnd.split(':').map(Number);
const endVal = endH * 60 + endM;
if (startVal < endVal) {
return currentTimeVal >= startVal && currentTimeVal < endVal;
} else {
return currentTimeVal >= startVal || currentTimeVal < endVal;
}
}
private async handleFailedTokens(tokens: string[]) {
this.logger.log(`Removing ${tokens.length} invalid tokens`);
await this.deviceRepo.delete({ deviceToken: In(tokens) });
}
}