@quatrain/messaging-firebase 1.0.2 → 1.0.3
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/LICENSE.md +15 -0
- package/package.json +39 -40
- package/src/FirebaseMessagingAdapter.ts +142 -0
- package/src/__test__/FirebaseMessagingAdapter.test.ts +175 -0
- package/src/index.ts +3 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# LICENSE UPDATE NOTICE
|
|
2
|
+
|
|
3
|
+
As of 01/01/2026, Quatrain Core is licensed under the **GNU Affero General Public License v3.0 (AGPL v3)**.
|
|
4
|
+
Previous versions remain under the MIT License.
|
|
5
|
+
|
|
6
|
+
## Why AGPL?
|
|
7
|
+
|
|
8
|
+
We believe in open collaboration for the development ecosystem. The AGPL ensures that any modification or deployment of this BaaS stack, including over a network, benefits the entire community.
|
|
9
|
+
|
|
10
|
+
## Commercial Services & Enterprise Usage
|
|
11
|
+
|
|
12
|
+
We provide official deployment services, technical training, and certification for Quatrain Core.
|
|
13
|
+
For organizations requiring a non-copyleft license (commercial license) or custom proprietary integrations, please contact the copyright holder: **Quatrain Technologies**.
|
|
14
|
+
|
|
15
|
+
Copyright © 2024-2026 Quatrain Technologies. All Rights Reserved.
|
package/package.json
CHANGED
|
@@ -1,41 +1,40 @@
|
|
|
1
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
|
-
}
|
|
2
|
+
"name": "@quatrain/messaging-firebase",
|
|
3
|
+
"version": "1.0.3",
|
|
4
|
+
"license": "AGPL-3.0-only",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"types": "lib/index.d.ts",
|
|
7
|
+
"bun": "src/index.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"LICENSE.md",
|
|
10
|
+
"src/",
|
|
11
|
+
"lib/",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"author": "Quatrain Développement SAS <developers@quatrain.com>",
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"@quatrain/core": "^1.1.43"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@quatrain/core": "^1.1.43",
|
|
20
|
+
"@quatrain/messaging": "^1.0.4",
|
|
21
|
+
"firebase-admin": "^13.0.1"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@tsconfig/recommended": "^1.0.1",
|
|
25
|
+
"@types/jest": "^27.0.3",
|
|
26
|
+
"@types/node": "^22.10.1",
|
|
27
|
+
"jest": "^30.2.0",
|
|
28
|
+
"jest-node-exports-resolver": "^1.1.6",
|
|
29
|
+
"jest-serial-runner": "^1.2.1",
|
|
30
|
+
"trace-unhandled": "^2.0.1",
|
|
31
|
+
"ts-jest": "^27.1.2",
|
|
32
|
+
"typescript": "^5.1.5"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc",
|
|
36
|
+
"wbuild": "tsc --watch",
|
|
37
|
+
"test": "jest",
|
|
38
|
+
"bump-to": "yarn version"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AbstractMessagingAdapter,
|
|
3
|
+
Messaging,
|
|
4
|
+
MessagingParameters,
|
|
5
|
+
MessagingRecipient,
|
|
6
|
+
NotificationCapableAdapter,
|
|
7
|
+
NotificationMessage,
|
|
8
|
+
} from '@quatrain/messaging'
|
|
9
|
+
import { credential } from 'firebase-admin'
|
|
10
|
+
import { getApps, initializeApp, ServiceAccount } from 'firebase-admin/app'
|
|
11
|
+
import { getMessaging, Message } from 'firebase-admin/messaging'
|
|
12
|
+
|
|
13
|
+
export class FirebaseMessagingAdapter
|
|
14
|
+
extends AbstractMessagingAdapter
|
|
15
|
+
implements NotificationCapableAdapter
|
|
16
|
+
{
|
|
17
|
+
protected _messaging: any
|
|
18
|
+
|
|
19
|
+
constructor(params: MessagingParameters = {}) {
|
|
20
|
+
super(params)
|
|
21
|
+
this._initializeFirebase()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
protected _initializeFirebase() {
|
|
25
|
+
try {
|
|
26
|
+
if (getApps().length === 0) {
|
|
27
|
+
if (!this._params.config) {
|
|
28
|
+
throw new Error('Firebase config is required')
|
|
29
|
+
}
|
|
30
|
+
const serviceAccount: ServiceAccount = this._params.config
|
|
31
|
+
initializeApp({
|
|
32
|
+
credential: credential.cert(serviceAccount),
|
|
33
|
+
databaseURL: this._params.config.databaseURL,
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
this._messaging = getMessaging()
|
|
37
|
+
} catch (error) {
|
|
38
|
+
Messaging.error(
|
|
39
|
+
`Failed to initialize Firebase: ${(error as Error).message}`
|
|
40
|
+
)
|
|
41
|
+
throw error
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Sends a notification to a single recipient via Firebase Cloud Messaging
|
|
47
|
+
*
|
|
48
|
+
* @param recipient - The message recipient containing contact info and FCM token
|
|
49
|
+
* @param message - The notification message with title, body, and optional data
|
|
50
|
+
* @returns Promise<string> - The Firebase message ID on successful delivery
|
|
51
|
+
* @throws Error if recipient has no message token or Firebase send fails
|
|
52
|
+
*/
|
|
53
|
+
async sendNotification(
|
|
54
|
+
recipient: MessagingRecipient,
|
|
55
|
+
message: NotificationMessage
|
|
56
|
+
): Promise<string> {
|
|
57
|
+
try {
|
|
58
|
+
if (!recipient.messageToken) {
|
|
59
|
+
throw new Error('Recipient message token is required')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const firebaseMessage: Message = {
|
|
63
|
+
token: message.token || recipient.messageToken,
|
|
64
|
+
notification: {
|
|
65
|
+
title: message.title,
|
|
66
|
+
body: message.body || '',
|
|
67
|
+
},
|
|
68
|
+
data: this._serializeData(message.data || {}),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const response = await this._messaging.send(firebaseMessage)
|
|
72
|
+
|
|
73
|
+
Messaging.info(
|
|
74
|
+
`Notification sent successfully to ${recipient.firstname} ${recipient.lastname}: ${response}`
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return response
|
|
78
|
+
} catch (error) {
|
|
79
|
+
const errorMessage = `Failed to send notification to ${
|
|
80
|
+
recipient.firstname
|
|
81
|
+
} ${recipient.lastname}: ${(error as Error).message}`
|
|
82
|
+
Messaging.error(errorMessage)
|
|
83
|
+
throw new Error(errorMessage)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async sendNotifications(
|
|
88
|
+
recipients: MessagingRecipient[],
|
|
89
|
+
message: NotificationMessage
|
|
90
|
+
): Promise<{
|
|
91
|
+
successCount: number
|
|
92
|
+
failureCount: number
|
|
93
|
+
responses: any[]
|
|
94
|
+
}> {
|
|
95
|
+
try {
|
|
96
|
+
const tokens = recipients
|
|
97
|
+
.map((recipient) => recipient.messageToken)
|
|
98
|
+
.filter((token) => token) as string[]
|
|
99
|
+
|
|
100
|
+
if (tokens.length === 0) {
|
|
101
|
+
throw new Error('No valid message tokens found')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const multicastMessage = {
|
|
105
|
+
tokens,
|
|
106
|
+
notification: {
|
|
107
|
+
title: message.title,
|
|
108
|
+
body: message.body || '',
|
|
109
|
+
},
|
|
110
|
+
data: this._serializeData(message.data || {}),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const response = await this._messaging.sendMulticast(multicastMessage)
|
|
114
|
+
|
|
115
|
+
Messaging.info(
|
|
116
|
+
`Multicast notification sent: ${response.successCount} successful, ${response.failureCount} failed`
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return response
|
|
120
|
+
} catch (error) {
|
|
121
|
+
const errorMessage = `Failed to send multicast notification: ${
|
|
122
|
+
(error as Error).message
|
|
123
|
+
}`
|
|
124
|
+
Messaging.error(errorMessage)
|
|
125
|
+
throw new Error(errorMessage)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private _serializeData(data: any): Record<string, string> {
|
|
130
|
+
const serialized: Record<string, string> = {}
|
|
131
|
+
|
|
132
|
+
for (const [key, value] of Object.entries(data)) {
|
|
133
|
+
if (typeof value === 'string') {
|
|
134
|
+
serialized[key] = value
|
|
135
|
+
} else {
|
|
136
|
+
serialized[key] = JSON.stringify(value)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return serialized
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { FirebaseMessagingAdapter } from '../FirebaseMessagingAdapter'
|
|
2
|
+
import { MessagingRecipient, NotificationMessage } from '@quatrain/messaging'
|
|
3
|
+
|
|
4
|
+
// Mock Firebase Admin SDK
|
|
5
|
+
jest.mock('firebase-admin/app', () => ({
|
|
6
|
+
getApps: jest.fn(() => []),
|
|
7
|
+
initializeApp: jest.fn()
|
|
8
|
+
}))
|
|
9
|
+
|
|
10
|
+
jest.mock('firebase-admin/messaging', () => ({
|
|
11
|
+
getMessaging: jest.fn(() => ({
|
|
12
|
+
send: jest.fn().mockResolvedValue('message-id-123'),
|
|
13
|
+
sendMulticast: jest.fn().mockResolvedValue({
|
|
14
|
+
successCount: 2,
|
|
15
|
+
failureCount: 0,
|
|
16
|
+
responses: [
|
|
17
|
+
{ success: true, messageId: 'msg-1' },
|
|
18
|
+
{ success: true, messageId: 'msg-2' }
|
|
19
|
+
]
|
|
20
|
+
})
|
|
21
|
+
}))
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
describe('FirebaseMessagingAdapter', () => {
|
|
25
|
+
let adapter: FirebaseMessagingAdapter
|
|
26
|
+
const mockConfig = {
|
|
27
|
+
projectId: 'test-project',
|
|
28
|
+
privateKey: 'mock-private-key',
|
|
29
|
+
clientEmail: 'test@test.com'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
jest.clearAllMocks()
|
|
34
|
+
adapter = new FirebaseMessagingAdapter({
|
|
35
|
+
config: mockConfig
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('constructor', () => {
|
|
40
|
+
test('should initialize with config', () => {
|
|
41
|
+
expect(adapter).toBeInstanceOf(FirebaseMessagingAdapter)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('should throw error without config', () => {
|
|
45
|
+
expect(() => {
|
|
46
|
+
new FirebaseMessagingAdapter({})
|
|
47
|
+
}).toThrow('Firebase config is required')
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('sendNotification', () => {
|
|
52
|
+
const mockRecipient: MessagingRecipient = {
|
|
53
|
+
firstname: 'John',
|
|
54
|
+
lastname: 'Doe',
|
|
55
|
+
email: 'john@example.com',
|
|
56
|
+
messageToken: 'mock-token-123'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const mockMessage: NotificationMessage = {
|
|
60
|
+
title: 'Test Notification',
|
|
61
|
+
body: 'This is a test message',
|
|
62
|
+
data: { userId: '123', action: 'test' }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
test('should send notification successfully', async () => {
|
|
66
|
+
const result = await adapter.sendNotification(mockRecipient, mockMessage)
|
|
67
|
+
expect(result).toBe('message-id-123')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('should throw error when recipient has no message token', async () => {
|
|
71
|
+
const recipientWithoutToken = { ...mockRecipient, messageToken: undefined }
|
|
72
|
+
|
|
73
|
+
await expect(
|
|
74
|
+
adapter.sendNotification(recipientWithoutToken, mockMessage)
|
|
75
|
+
).rejects.toThrow('Recipient message token is required')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('should use message token from message if provided', async () => {
|
|
79
|
+
const messageWithToken = { ...mockMessage, token: 'custom-token' }
|
|
80
|
+
|
|
81
|
+
const result = await adapter.sendNotification(mockRecipient, messageWithToken)
|
|
82
|
+
expect(result).toBe('message-id-123')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('should handle empty body gracefully', async () => {
|
|
86
|
+
const messageWithoutBody = { ...mockMessage, body: undefined }
|
|
87
|
+
|
|
88
|
+
const result = await adapter.sendNotification(mockRecipient, messageWithoutBody)
|
|
89
|
+
expect(result).toBe('message-id-123')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('sendMulticast', () => {
|
|
94
|
+
const mockRecipients: MessagingRecipient[] = [
|
|
95
|
+
{
|
|
96
|
+
firstname: 'John',
|
|
97
|
+
lastname: 'Doe',
|
|
98
|
+
email: 'john@example.com',
|
|
99
|
+
messageToken: 'token-1'
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
firstname: 'Jane',
|
|
103
|
+
lastname: 'Smith',
|
|
104
|
+
email: 'jane@example.com',
|
|
105
|
+
messageToken: 'token-2'
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
const mockMessage: NotificationMessage = {
|
|
110
|
+
title: 'Multicast Test',
|
|
111
|
+
body: 'This is a multicast message'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
test('should send multicast notification successfully', async () => {
|
|
115
|
+
const result = await adapter.sendMulticast(mockRecipients, mockMessage)
|
|
116
|
+
|
|
117
|
+
expect(result.successCount).toBe(2)
|
|
118
|
+
expect(result.failureCount).toBe(0)
|
|
119
|
+
expect(result.responses).toHaveLength(2)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('should filter out recipients without tokens', async () => {
|
|
123
|
+
const recipientsWithMissingTokens = [
|
|
124
|
+
...mockRecipients,
|
|
125
|
+
{
|
|
126
|
+
firstname: 'Bob',
|
|
127
|
+
lastname: 'Wilson',
|
|
128
|
+
email: 'bob@example.com',
|
|
129
|
+
messageToken: undefined
|
|
130
|
+
}
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
const result = await adapter.sendMulticast(recipientsWithMissingTokens, mockMessage)
|
|
134
|
+
expect(result.successCount).toBe(2)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('should throw error when no valid tokens found', async () => {
|
|
138
|
+
const recipientsWithoutTokens = mockRecipients.map(r => ({
|
|
139
|
+
...r,
|
|
140
|
+
messageToken: undefined
|
|
141
|
+
}))
|
|
142
|
+
|
|
143
|
+
await expect(
|
|
144
|
+
adapter.sendMulticast(recipientsWithoutTokens, mockMessage)
|
|
145
|
+
).rejects.toThrow('No valid message tokens found')
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('_serializeData', () => {
|
|
150
|
+
test('should serialize complex data correctly', () => {
|
|
151
|
+
const adapter = new FirebaseMessagingAdapter({ config: mockConfig })
|
|
152
|
+
const testData = {
|
|
153
|
+
stringValue: 'test',
|
|
154
|
+
numberValue: 123,
|
|
155
|
+
booleanValue: true,
|
|
156
|
+
objectValue: { nested: 'object' },
|
|
157
|
+
arrayValue: [1, 2, 3]
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const result = (adapter as any)._serializeData(testData)
|
|
161
|
+
|
|
162
|
+
expect(result.stringValue).toBe('test')
|
|
163
|
+
expect(result.numberValue).toBe('123')
|
|
164
|
+
expect(result.booleanValue).toBe('true')
|
|
165
|
+
expect(result.objectValue).toBe('{"nested":"object"}')
|
|
166
|
+
expect(result.arrayValue).toBe('[1,2,3]')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('should handle empty data', () => {
|
|
170
|
+
const adapter = new FirebaseMessagingAdapter({ config: mockConfig })
|
|
171
|
+
const result = (adapter as any)._serializeData({})
|
|
172
|
+
expect(result).toEqual({})
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
})
|
package/src/index.ts
ADDED