vasuzex 2.1.12 → 2.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/framework/Services/Notification/Channels/FcmChannel.js +201 -0
- package/framework/Services/Notification/FirebaseAdmin.js +90 -0
- package/framework/Services/Notification/NotificationManager.js +12 -0
- package/frontend/react-ui/components/DataTable/TableBody.jsx +3 -3
- package/frontend/react-ui/components/PhotoManager/EnhancedPhotoManager.jsx +8 -0
- package/package.json +2 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FCM Notification Channel
|
|
3
|
+
* Firebase Cloud Messaging channel for push notifications
|
|
4
|
+
*
|
|
5
|
+
* Features:
|
|
6
|
+
* - Multi-device notification (sends to all user devices)
|
|
7
|
+
* - Automatic invalid token cleanup
|
|
8
|
+
* - Platform-specific configurations
|
|
9
|
+
* - Respects user notification preferences
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Channel } from '../Channel.js';
|
|
13
|
+
import { getMessaging } from '../FirebaseAdmin.js';
|
|
14
|
+
import { Log } from '../../../Support/Facades/index.js';
|
|
15
|
+
|
|
16
|
+
export class FcmChannel extends Channel {
|
|
17
|
+
constructor(tokenRepository) {
|
|
18
|
+
super();
|
|
19
|
+
this.tokenRepository = tokenRepository; // Repository for user_device_tokens
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Send notification via FCM
|
|
24
|
+
* @param {Object} notifiable - User object with id
|
|
25
|
+
* @param {Object} notification - Notification instance
|
|
26
|
+
*/
|
|
27
|
+
async send(notifiable, notification) {
|
|
28
|
+
const messaging = getMessaging();
|
|
29
|
+
|
|
30
|
+
if (!messaging) {
|
|
31
|
+
Log.warning('FCM not available - skipping push notification', {
|
|
32
|
+
userId: notifiable.id,
|
|
33
|
+
notificationType: notification.constructor.name
|
|
34
|
+
});
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Get FCM message from notification
|
|
39
|
+
const fcmData = notification.toFcm
|
|
40
|
+
? notification.toFcm(notifiable)
|
|
41
|
+
: this.buildDefaultMessage(notification, notifiable);
|
|
42
|
+
|
|
43
|
+
if (!fcmData) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Get all active tokens for user
|
|
48
|
+
const tokens = await this.getActiveTokens(notifiable.id);
|
|
49
|
+
|
|
50
|
+
if (tokens.length === 0) {
|
|
51
|
+
Log.debug('No FCM tokens found for user', { userId: notifiable.id });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Send to all devices
|
|
56
|
+
await this.sendToMultipleDevices(tokens, fcmData, notifiable.id);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get active FCM tokens for user
|
|
61
|
+
*/
|
|
62
|
+
async getActiveTokens(userId) {
|
|
63
|
+
if (!this.tokenRepository) {
|
|
64
|
+
Log.error('Token repository not configured for FcmChannel');
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return await this.tokenRepository.getActiveTokensForUser(userId);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Send notification to multiple devices
|
|
73
|
+
*/
|
|
74
|
+
async sendToMultipleDevices(tokens, fcmData, userId) {
|
|
75
|
+
const messaging = getMessaging();
|
|
76
|
+
const tokenStrings = tokens.map(t => t.token);
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const message = {
|
|
80
|
+
notification: {
|
|
81
|
+
title: fcmData.title,
|
|
82
|
+
body: fcmData.body,
|
|
83
|
+
...(fcmData.imageUrl && { imageUrl: fcmData.imageUrl })
|
|
84
|
+
},
|
|
85
|
+
data: this.stringifyData(fcmData.data || {}),
|
|
86
|
+
tokens: tokenStrings
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Add platform-specific config
|
|
90
|
+
if (fcmData.android) {
|
|
91
|
+
message.android = fcmData.android;
|
|
92
|
+
}
|
|
93
|
+
if (fcmData.apns) {
|
|
94
|
+
message.apns = fcmData.apns;
|
|
95
|
+
}
|
|
96
|
+
if (fcmData.webpush) {
|
|
97
|
+
message.webpush = fcmData.webpush;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const response = await messaging.sendEachForMulticast(message);
|
|
101
|
+
|
|
102
|
+
Log.info('FCM multicast sent', {
|
|
103
|
+
userId,
|
|
104
|
+
successCount: response.successCount,
|
|
105
|
+
failureCount: response.failureCount,
|
|
106
|
+
totalTokens: tokenStrings.length
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Handle failed tokens
|
|
110
|
+
if (response.failureCount > 0) {
|
|
111
|
+
await this.handleFailedTokens(tokens, response.responses, userId);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Update last_used_at for successful sends
|
|
115
|
+
const successfulTokenIds = tokens
|
|
116
|
+
.filter((_, index) => response.responses[index].success)
|
|
117
|
+
.map(t => t.id);
|
|
118
|
+
|
|
119
|
+
if (successfulTokenIds.length > 0 && this.tokenRepository) {
|
|
120
|
+
await this.tokenRepository.updateLastUsed(successfulTokenIds);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return response;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
Log.error('FCM multicast failed', {
|
|
126
|
+
userId,
|
|
127
|
+
error: error.message,
|
|
128
|
+
code: error.code
|
|
129
|
+
});
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Handle failed token deliveries
|
|
136
|
+
* Removes invalid/expired tokens
|
|
137
|
+
*/
|
|
138
|
+
async handleFailedTokens(tokens, responses, userId) {
|
|
139
|
+
const invalidTokenIds = [];
|
|
140
|
+
const invalidReasons = [
|
|
141
|
+
'messaging/invalid-registration-token',
|
|
142
|
+
'messaging/registration-token-not-registered',
|
|
143
|
+
'messaging/invalid-argument'
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
responses.forEach((response, index) => {
|
|
147
|
+
if (!response.success && response.error) {
|
|
148
|
+
const errorCode = response.error.code;
|
|
149
|
+
|
|
150
|
+
if (invalidReasons.includes(errorCode)) {
|
|
151
|
+
invalidTokenIds.push(tokens[index].id);
|
|
152
|
+
Log.info('Removing invalid FCM token', {
|
|
153
|
+
userId,
|
|
154
|
+
tokenId: tokens[index].id,
|
|
155
|
+
platform: tokens[index].platform,
|
|
156
|
+
errorCode
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (invalidTokenIds.length > 0 && this.tokenRepository) {
|
|
163
|
+
await this.tokenRepository.deactivateTokens(invalidTokenIds);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Build default FCM message from notification
|
|
169
|
+
*/
|
|
170
|
+
buildDefaultMessage(notification, notifiable) {
|
|
171
|
+
// Try to get data from toArray or toDatabase
|
|
172
|
+
const data = notification.toArray
|
|
173
|
+
? notification.toArray(notifiable)
|
|
174
|
+
: (notification.toDatabase ? notification.toDatabase(notifiable) : null);
|
|
175
|
+
|
|
176
|
+
if (!data) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
title: data.title || 'Notification',
|
|
182
|
+
body: data.body || data.message || '',
|
|
183
|
+
data: data.data || {},
|
|
184
|
+
imageUrl: data.imageUrl || data.image_url
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Stringify all data values for FCM
|
|
190
|
+
* FCM data payload must have string values only
|
|
191
|
+
*/
|
|
192
|
+
stringifyData(data) {
|
|
193
|
+
const stringified = {};
|
|
194
|
+
for (const [key, value] of Object.entries(data)) {
|
|
195
|
+
stringified[key] = typeof value === 'string' ? value : JSON.stringify(value);
|
|
196
|
+
}
|
|
197
|
+
return stringified;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export default FcmChannel;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Firebase Admin SDK Singleton
|
|
3
|
+
* Centralized Firebase Admin initialization for FCM
|
|
4
|
+
*
|
|
5
|
+
* Configuration from environment variables:
|
|
6
|
+
* - FIREBASE_PROJECT_ID
|
|
7
|
+
* - FIREBASE_CLIENT_EMAIL
|
|
8
|
+
* - FIREBASE_PRIVATE_KEY
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import admin from 'firebase-admin';
|
|
12
|
+
import { Config, Log } from '../../Support/Facades/index.js';
|
|
13
|
+
|
|
14
|
+
let firebaseApp = null;
|
|
15
|
+
let isInitialized = false;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Initialize Firebase Admin SDK
|
|
19
|
+
* Safe to call multiple times - will only initialize once
|
|
20
|
+
*/
|
|
21
|
+
export function initializeFirebaseAdmin() {
|
|
22
|
+
if (isInitialized) {
|
|
23
|
+
return firebaseApp;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const projectId = Config.get('notification.fcm.project_id') || process.env.FIREBASE_PROJECT_ID;
|
|
28
|
+
const clientEmail = Config.get('notification.fcm.client_email') || process.env.FIREBASE_CLIENT_EMAIL;
|
|
29
|
+
const privateKey = Config.get('notification.fcm.private_key') || process.env.FIREBASE_PRIVATE_KEY;
|
|
30
|
+
|
|
31
|
+
if (!projectId || !clientEmail || !privateKey) {
|
|
32
|
+
Log.warning('Firebase Admin SDK not configured - FCM notifications disabled', {
|
|
33
|
+
hasProjectId: !!projectId,
|
|
34
|
+
hasClientEmail: !!clientEmail,
|
|
35
|
+
hasPrivateKey: !!privateKey
|
|
36
|
+
});
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Handle escaped newlines in private key
|
|
41
|
+
const formattedPrivateKey = privateKey.replace(/\\n/g, '\n');
|
|
42
|
+
|
|
43
|
+
firebaseApp = admin.initializeApp({
|
|
44
|
+
credential: admin.credential.cert({
|
|
45
|
+
projectId,
|
|
46
|
+
clientEmail,
|
|
47
|
+
privateKey: formattedPrivateKey,
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
isInitialized = true;
|
|
52
|
+
Log.info('Firebase Admin SDK initialized successfully', { projectId });
|
|
53
|
+
|
|
54
|
+
return firebaseApp;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
Log.error('Failed to initialize Firebase Admin SDK', {
|
|
57
|
+
error: error.message,
|
|
58
|
+
stack: error.stack
|
|
59
|
+
});
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get Firebase Messaging instance
|
|
66
|
+
*/
|
|
67
|
+
export function getMessaging() {
|
|
68
|
+
if (!isInitialized) {
|
|
69
|
+
initializeFirebaseAdmin();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!firebaseApp) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return admin.messaging();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check if Firebase Admin is initialized
|
|
81
|
+
*/
|
|
82
|
+
export function isFirebaseInitialized() {
|
|
83
|
+
return isInitialized && firebaseApp !== null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export default {
|
|
87
|
+
initialize: initializeFirebaseAdmin,
|
|
88
|
+
getMessaging,
|
|
89
|
+
isInitialized: isFirebaseInitialized
|
|
90
|
+
};
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { MailChannel } from './Channels/MailChannel.js';
|
|
7
7
|
import { SmsChannel } from './Channels/SmsChannel.js';
|
|
8
8
|
import { DatabaseChannel } from './Channels/DatabaseChannel.js';
|
|
9
|
+
import { FcmChannel } from './Channels/FcmChannel.js';
|
|
9
10
|
|
|
10
11
|
export class NotificationManager {
|
|
11
12
|
constructor(app) {
|
|
@@ -125,6 +126,17 @@ export class NotificationManager {
|
|
|
125
126
|
return new DatabaseChannel(database);
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Create an instance of the FCM driver
|
|
131
|
+
*/
|
|
132
|
+
createFcmDriver() {
|
|
133
|
+
// Get token repository from app container
|
|
134
|
+
const tokenRepository = this.app.has('tokenRepository')
|
|
135
|
+
? this.app.make('tokenRepository')
|
|
136
|
+
: null;
|
|
137
|
+
return new FcmChannel(tokenRepository);
|
|
138
|
+
}
|
|
139
|
+
|
|
128
140
|
/**
|
|
129
141
|
* Get the default channel name
|
|
130
142
|
*/
|
|
@@ -136,9 +136,9 @@ export function TableBody({
|
|
|
136
136
|
// Get icon, class, title, and content
|
|
137
137
|
const Icon = action.icon;
|
|
138
138
|
const className =
|
|
139
|
-
typeof action.
|
|
140
|
-
? action.
|
|
141
|
-
: action.
|
|
139
|
+
typeof action.className === "function"
|
|
140
|
+
? action.className(row)
|
|
141
|
+
: action.className || "";
|
|
142
142
|
const title =
|
|
143
143
|
typeof action.title === "function"
|
|
144
144
|
? action.title(row)
|
|
@@ -63,8 +63,10 @@ export function EnhancedPhotoManager({
|
|
|
63
63
|
description,
|
|
64
64
|
emptyStateText = "No photos uploaded yet",
|
|
65
65
|
maxFiles = 20,
|
|
66
|
+
maxFileSize = 5 * 1024 * 1024, // 5MB default
|
|
66
67
|
gridCols = "grid-cols-2 sm:grid-cols-3 md:grid-cols-4",
|
|
67
68
|
components = {},
|
|
69
|
+
dropzoneProps = {},
|
|
68
70
|
}) {
|
|
69
71
|
const {
|
|
70
72
|
photos = [],
|
|
@@ -210,6 +212,8 @@ export function EnhancedPhotoManager({
|
|
|
210
212
|
onFilesSelected={handleFilesSelected}
|
|
211
213
|
disabled={loading}
|
|
212
214
|
maxFiles={maxFiles - sortedPhotos.length}
|
|
215
|
+
maxSize={dropzoneProps.maxSize || maxFileSize}
|
|
216
|
+
{...dropzoneProps}
|
|
213
217
|
/>
|
|
214
218
|
)}
|
|
215
219
|
|
|
@@ -299,6 +303,8 @@ EnhancedPhotoManager.propTypes = {
|
|
|
299
303
|
emptyStateText: PropTypes.string,
|
|
300
304
|
/** Maximum number of photos */
|
|
301
305
|
maxFiles: PropTypes.number,
|
|
306
|
+
/** Maximum file size in bytes (default 5MB) */
|
|
307
|
+
maxFileSize: PropTypes.number,
|
|
302
308
|
/** Grid columns CSS classes */
|
|
303
309
|
gridCols: PropTypes.string,
|
|
304
310
|
/** Custom components */
|
|
@@ -308,6 +314,8 @@ EnhancedPhotoManager.propTypes = {
|
|
|
308
314
|
UploadProgressCard: PropTypes.elementType,
|
|
309
315
|
TrashIcon: PropTypes.elementType,
|
|
310
316
|
}),
|
|
317
|
+
/** Additional props to pass to UploadDropZone */
|
|
318
|
+
dropzoneProps: PropTypes.object,
|
|
311
319
|
};
|
|
312
320
|
|
|
313
321
|
export default EnhancedPhotoManager;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vasuzex",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.14",
|
|
4
4
|
"description": "Laravel-inspired framework for Node.js monorepos - V2 with optimized dependencies",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./framework/index.js",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"./Support/Facades": "./framework/Support/Facades/index.js",
|
|
20
20
|
"./Support/Facades/*": "./framework/Support/Facades/*.js",
|
|
21
21
|
"./Services/*": "./framework/Services/*/index.js",
|
|
22
|
+
"./framework/*": "./framework/*",
|
|
22
23
|
"./client": "./frontend/client/index.js",
|
|
23
24
|
"./client/*": "./frontend/client/*/index.js",
|
|
24
25
|
"./react": "./frontend/react-ui/index.js",
|