Skip to content

Commit e1ebc38

Browse files
authored
feat(condo): DOMA-12996 push adapter for OneSignal (#7303)
* feat(condo): DOMA-12996 push adapter for OneSignal * fix(condo): DOMA-12996 try to fix yarn.lock * fix(condo): DOMA-12996 make OneSignal processible in deliverMessage * fix(condo): DOMA-12996 after review fixes * fix(condo): DOMA-12996 fix onesignal package version * fix(condo): DOMA-12996 maketypes * fix(condo): DOMA-12996 after review fixes * fix(condo): DOMA-12996 fix test
1 parent d4f0d6b commit e1ebc38

9 files changed

Lines changed: 572 additions & 31 deletions

File tree

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
const OneSignal = require('@onesignal/node-onesignal')
2+
const z = require('zod')
3+
4+
const { generateUUIDv4 } = require('@open-condo/miniapp-utils/helpers/uuid')
5+
6+
const { PUSH_TYPE_SILENT_DATA } = require('@condo/domains/notification/constants/constants')
7+
8+
const HIGHEST_PRIORITY = 10
9+
const APNS_PUSH_TYPE_OVERRIDE_VOIP = 'voip'
10+
const TARGET_CHANNEL = 'push'
11+
12+
13+
14+
const CONFIG_SCHEMA = z.object({
15+
providerAppId: z.string(),
16+
apiKey: z.string(),
17+
})
18+
19+
class OneSignalNotificationSender {
20+
/** @type {import('@onesignal/node-onesignal').DefaultApi} */
21+
_app
22+
providerAppId
23+
24+
constructor (config) {
25+
const { success, data: parsedConfig, error } = CONFIG_SCHEMA.safeParse(config)
26+
if (!success) {
27+
throw new AggregateError([
28+
Error('OneSignalNotificationSender config is invalid'),
29+
error,
30+
])
31+
}
32+
const oneSignalConfiguration = OneSignal.createConfiguration({ restApiKey: parsedConfig.apiKey })
33+
this._app = new OneSignal.DefaultApi(oneSignalConfiguration)
34+
this.providerAppId = parsedConfig.providerAppId
35+
}
36+
37+
/**
38+
* @param notification {import('@onesignal/node-onesignal').Notification}
39+
* @returns {Promise<{
40+
* notification: import('@onesignal/node-onesignal').Notification,
41+
* response?: import('@onesignal/node-onesignal').CreateNotificationSuccessResponse,
42+
* error?: Error
43+
* }>}
44+
*/
45+
async sendPush (notification) {
46+
try {
47+
notification.idempotency_key ??= generateUUIDv4()
48+
return { notification, response: await this._app.createNotification(notification) }
49+
} catch (err) {
50+
return { notification, error: err }
51+
}
52+
}
53+
54+
/**
55+
* @param notifications
56+
* @param isVoIP
57+
* @returns {Promise<{responses: {token: string, success: boolean, response: import('@onesignal/node-onesignal').CreateNotificationSuccessResponse | null, error?: Error}[], successCount: number, failureCount: number}>}
58+
*/
59+
async sendAll (notifications, isVoIP = false) {
60+
const responses = []
61+
let successCount = 0, failureCount = 0
62+
63+
const providerNotificationsObjects = this._buildPayloadsForProvider(notifications, isVoIP)
64+
65+
const promises = await Promise.allSettled(providerNotificationsObjects.map(async (providerNotificationsObject) => {
66+
return await this.sendPush(providerNotificationsObject)
67+
}))
68+
69+
for (const p of promises) {
70+
if (p.status !== 'fulfilled') {
71+
responses.push({ error: p.reason })
72+
failureCount += 1
73+
continue
74+
}
75+
/** @type {import('@onesignal/node-onesignal').CreateNotificationSuccessResponse} */
76+
const { notification, response, error } = p.value
77+
if (error) {
78+
responses.push(...notification.include_subscription_ids.map(token => ({
79+
token,
80+
success: false,
81+
response,
82+
error,
83+
})))
84+
failureCount += notification.include_subscription_ids.length
85+
} else {
86+
const didSendMessageToSomebody = !!response.id
87+
88+
// TODO(YEgorLu): DOMA-12941 add token invalidation for provider using that:
89+
// if (response.errors) {
90+
// if (response.errors.includes('All included players are not subscribed')) {
91+
// invalidSubscriptionIds = notification.include_subscription_ids
92+
// }
93+
// else if (response.errors.invalid_player_ids) {
94+
// invalidSubscriptionIds = response.errors.invalid_player_ids
95+
// }
96+
// }
97+
98+
if (didSendMessageToSomebody) {
99+
const surelyNonSuccessTokens = (response.errors?.invalid_player_ids ?? [])
100+
101+
responses.push(...notification.include_subscription_ids.map(token => {
102+
const success = !surelyNonSuccessTokens.includes(token)
103+
return {
104+
token,
105+
success,
106+
response,
107+
}
108+
}))
109+
successCount += notification.include_subscription_ids.length - surelyNonSuccessTokens.length
110+
failureCount += surelyNonSuccessTokens.length
111+
} else {
112+
responses.push(...notification.include_subscription_ids.map(token => ({
113+
token,
114+
success: false,
115+
response,
116+
})))
117+
failureCount += notification.include_subscription_ids.length
118+
}
119+
}
120+
}
121+
122+
return { responses, successCount, failureCount }
123+
}
124+
125+
/**
126+
* Dedupes notifications by their content and builds payloads for provider
127+
* @param notifications
128+
* @param isVoIP
129+
* @returns {Notification[]}
130+
* @private
131+
*/
132+
_buildPayloadsForProvider (notifications, isVoIP) {
133+
const sameNotificationForMultipleTokensByBody = {}
134+
notifications.forEach(notification => {
135+
const body = { data: notification.data, notification: notification.notification, type: notification.type }
136+
const bodyStringified = JSON.stringify(body)
137+
if (!sameNotificationForMultipleTokensByBody[bodyStringified]) sameNotificationForMultipleTokensByBody[bodyStringified] = {
138+
data: notification.data,
139+
notification: notification.notification,
140+
type: notification.type,
141+
tokens: [],
142+
}
143+
sameNotificationForMultipleTokensByBody[bodyStringified].tokens.push(notification.token)
144+
})
145+
146+
return Object.values(sameNotificationForMultipleTokensByBody).map(({ tokens, type, data, notification }) => {
147+
const providerNotification = new OneSignal.Notification()
148+
149+
providerNotification.app_id = this.providerAppId
150+
providerNotification.include_subscription_ids = tokens
151+
providerNotification.data = data
152+
providerNotification.target_channel = TARGET_CHANNEL
153+
providerNotification.mutable_content = true
154+
155+
if (type === PUSH_TYPE_SILENT_DATA) {
156+
providerNotification.content_available = true
157+
} else {
158+
providerNotification.headings = { en: notification.title } // NOTE(YEgorLu): provide only one language, it will be used for everyone
159+
providerNotification.contents = { en: notification.body } // NOTE(YEgorLu): provide only one language, it will be used for everyone
160+
}
161+
162+
if (isVoIP) {
163+
providerNotification.apns_push_type_override = APNS_PUSH_TYPE_OVERRIDE_VOIP
164+
providerNotification.priority = HIGHEST_PRIORITY
165+
}
166+
167+
return providerNotification
168+
})
169+
}
170+
171+
}
172+
173+
module.exports = { OneSignalNotificationSender }

0 commit comments

Comments
 (0)