From 9344fae1b90e36caf20eef9ac8b3ca9aa30430c2 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 22 Nov 2022 10:50:38 +0800 Subject: [PATCH 1/5] Add title and image url in notification --- .../api/src/services/save_newsletter_email.ts | 28 +++++++++---------- packages/rule-handler/src/index.ts | 3 +- packages/rule-handler/src/rule.ts | 18 ++++++++++-- packages/rule-handler/src/sendNotification.ts | 6 +++- 4 files changed, 35 insertions(+), 20 deletions(-) diff --git a/packages/api/src/services/save_newsletter_email.ts b/packages/api/src/services/save_newsletter_email.ts index 0f505c53b..365877d08 100644 --- a/packages/api/src/services/save_newsletter_email.ts +++ b/packages/api/src/services/save_newsletter_email.ts @@ -4,10 +4,8 @@ import { UserDeviceToken } from '../entity/user_device_tokens' import { env } from '../env' import { ContentReader } from '../generated/graphql' import { analytics } from '../utils/analytics' -import { sendMulticastPushNotifications } from '../utils/sendNotification' import { getNewsletterEmail } from './newsletters' import { SaveContext, saveEmail, SaveEmailInput } from './save_email' -import { getDeviceTokensByUserId } from './user_device_tokens' import { Page } from '../elastic/types' import { addLabelToPage } from './labels' import { saveSubscription } from './subscriptions' @@ -97,19 +95,19 @@ export const saveNewsletterEmail = async ( }) console.log('newsletter label added:', result) - // sends push notification - const deviceTokens = await getDeviceTokensByUserId(newsletterEmail.user.id) - if (!deviceTokens) { - console.log('Device tokens not set:', newsletterEmail.user.id) - return true - } - - const multicastMessage = messageForLink(page, deviceTokens) - await sendMulticastPushNotifications( - newsletterEmail.user.id, - multicastMessage, - 'newsletter' - ) + // // sends push notification + // const deviceTokens = await getDeviceTokensByUserId(newsletterEmail.user.id) + // if (!deviceTokens) { + // console.log('Device tokens not set:', newsletterEmail.user.id) + // return true + // } + // + // const multicastMessage = messageForLink(page, deviceTokens) + // await sendMulticastPushNotifications( + // newsletterEmail.user.id, + // multicastMessage, + // 'newsletter' + // ) return true } diff --git a/packages/rule-handler/src/index.ts b/packages/rule-handler/src/index.ts index 6914ce6dd..16e800d2f 100644 --- a/packages/rule-handler/src/index.ts +++ b/packages/rule-handler/src/index.ts @@ -19,9 +19,10 @@ interface PubSubRequestBody { } export interface PubSubData { - subscription: string userId: string type: EntityType + subscription?: string + image?: string } enum EntityType { diff --git a/packages/rule-handler/src/rule.ts b/packages/rule-handler/src/rule.ts index 7343fcdfc..2e57f1a88 100644 --- a/packages/rule-handler/src/rule.ts +++ b/packages/rule-handler/src/rule.ts @@ -94,7 +94,14 @@ export const triggerActions = async ( console.log('No notification messages provided') continue } - await sendNotification(userId, action.params, apiEndpoint, jwtSecret) + await sendNotification( + userId, + data.subscription, + action.params, + apiEndpoint, + jwtSecret, + data.image + ) } } } @@ -102,16 +109,21 @@ export const triggerActions = async ( export const sendNotification = async ( userId: string, + subscription: string, messages: string[], apiEndpoint: string, - jwtSecret: string + jwtSecret: string, + image?: string ) => { + const title = `📫 - ${subscription} has published a new article` // get device tokens by calling api const tokens = await getDeviceTokens(userId, apiEndpoint, jwtSecret) const batchMessages = getBatchMessages( + title, messages, - tokens.map((t) => t.token) + tokens.map((t) => t.token), + image ) return sendBatchPushNotifications(batchMessages) diff --git a/packages/rule-handler/src/sendNotification.ts b/packages/rule-handler/src/sendNotification.ts index 684f858e6..c7cad5abc 100644 --- a/packages/rule-handler/src/sendNotification.ts +++ b/packages/rule-handler/src/sendNotification.ts @@ -56,8 +56,10 @@ export const getDeviceTokens = async ( } export const getBatchMessages = ( + title: string, messages: string[], - tokens: string[] + tokens: string[], + imageUrl?: string ): Message[] => { const batchMessages: Message[] = [] messages.forEach((message) => { @@ -65,7 +67,9 @@ export const getBatchMessages = ( batchMessages.push({ token, notification: { + title, body: message, + imageUrl, }, }) }) From f7b0981b7d2a02a89dd3ca569f9921e8e52f0b8e Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 22 Nov 2022 12:12:12 +0800 Subject: [PATCH 2/5] Parse subscription filter in rules --- packages/rule-handler/package.json | 3 +- .../{sendNotification.ts => notification.ts} | 7 +- packages/rule-handler/src/rule.ts | 74 +++++++++++++++---- 3 files changed, 63 insertions(+), 21 deletions(-) rename packages/rule-handler/src/{sendNotification.ts => notification.ts} (94%) diff --git a/packages/rule-handler/package.json b/packages/rule-handler/package.json index 6c17ca61b..41f80a65e 100644 --- a/packages/rule-handler/package.json +++ b/packages/rule-handler/package.json @@ -25,6 +25,7 @@ "axios": "^0.27.2", "dotenv": "^16.0.1", "firebase-admin": "^10.0.2", - "jsonwebtoken": "^8.5.1" + "jsonwebtoken": "^8.5.1", + "search-query-parser": "^1.6.0" } } diff --git a/packages/rule-handler/src/sendNotification.ts b/packages/rule-handler/src/notification.ts similarity index 94% rename from packages/rule-handler/src/sendNotification.ts rename to packages/rule-handler/src/notification.ts index c7cad5abc..31bfc20ca 100644 --- a/packages/rule-handler/src/sendNotification.ts +++ b/packages/rule-handler/src/notification.ts @@ -56,9 +56,9 @@ export const getDeviceTokens = async ( } export const getBatchMessages = ( - title: string, messages: string[], tokens: string[], + title?: string, imageUrl?: string ): Message[] => { const batchMessages: Message[] = [] @@ -93,8 +93,5 @@ export const sendMulticastPushNotifications = async ( export const sendBatchPushNotifications = async ( messages: Message[] ): Promise => { - const res = await getMessaging().sendAll(messages) - console.debug('res', res) - - return res + return getMessaging().sendAll(messages) } diff --git a/packages/rule-handler/src/rule.ts b/packages/rule-handler/src/rule.ts index 2e57f1a88..990de516d 100644 --- a/packages/rule-handler/src/rule.ts +++ b/packages/rule-handler/src/rule.ts @@ -2,9 +2,10 @@ import { getBatchMessages, getDeviceTokens, sendBatchPushNotifications, -} from './sendNotification' +} from './notification' import { getAuthToken, PubSubData } from './index' import axios from 'axios' +import { parse, SearchParserKeyWordOffset } from 'search-query-parser' export enum RuleActionType { AddLabel = 'ADD_LABEL', @@ -30,6 +31,59 @@ export interface Rule { updatedAt: Date } +interface SearchFilter { + subscriptionFilter?: string +} + +const parseSearchFilter = (filter: string): SearchFilter => { + const searchFilter = filter ? filter.replace(/\W\s":/g, '') : undefined + const result: SearchFilter = {} + + if (!searchFilter || searchFilter === '*') { + return result + } + + const parsed = parse(searchFilter, { + keywords: ['subscription'], + tokenize: true, + }) + if (parsed.offsets) { + const keywords = parsed.offsets + .filter((offset) => 'keyword' in offset) + .map((offset) => offset as SearchParserKeyWordOffset) + + for (const keyword of keywords) { + switch (keyword.keyword) { + case 'subscription': + result.subscriptionFilter = keyword.value + } + } + } + + return result +} + +const isValidData = (filter: string, data: PubSubData): boolean => { + const searchFilter = parseSearchFilter(filter) + + if (searchFilter.subscriptionFilter) { + return isValidSubscription(searchFilter.subscriptionFilter, data) + } + + return true +} + +const isValidSubscription = ( + subscriptionFilter: string, + data: PubSubData +): boolean => { + if (!data.subscription) { + return false + } + + return subscriptionFilter === '*' || data.subscription === subscriptionFilter +} + export const getEnabledRules = async ( userId: string, apiEndpoint: string, @@ -77,9 +131,7 @@ export const triggerActions = async ( jwtSecret: string ) => { for (const rule of rules) { - // TODO: filter out rules that don't match the trigger - if (!data.subscription) { - console.debug('no subscription') + if (!isValidData(rule.filter, data)) { continue } @@ -94,14 +146,7 @@ export const triggerActions = async ( console.log('No notification messages provided') continue } - await sendNotification( - userId, - data.subscription, - action.params, - apiEndpoint, - jwtSecret, - data.image - ) + await sendNotification(userId, action.params, apiEndpoint, jwtSecret) } } } @@ -109,20 +154,19 @@ export const triggerActions = async ( export const sendNotification = async ( userId: string, - subscription: string, messages: string[], apiEndpoint: string, jwtSecret: string, + title?: string, image?: string ) => { - const title = `📫 - ${subscription} has published a new article` // get device tokens by calling api const tokens = await getDeviceTokens(userId, apiEndpoint, jwtSecret) const batchMessages = getBatchMessages( - title, messages, tokens.map((t) => t.token), + title, image ) From 2ae3226bdb728bdec0f4fb87452d3b714a0f0933 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 22 Nov 2022 12:48:17 +0800 Subject: [PATCH 3/5] Add sendNotification api --- .../api/src/routers/notification_router.ts | 73 +++++++++++++ packages/api/src/server.ts | 2 + packages/api/src/utils/sendNotification.ts | 2 +- packages/rule-handler/src/notification.ts | 100 ++++-------------- packages/rule-handler/src/rule.ts | 33 +----- 5 files changed, 98 insertions(+), 112 deletions(-) create mode 100644 packages/api/src/routers/notification_router.ts diff --git a/packages/api/src/routers/notification_router.ts b/packages/api/src/routers/notification_router.ts new file mode 100644 index 000000000..14e44303d --- /dev/null +++ b/packages/api/src/routers/notification_router.ts @@ -0,0 +1,73 @@ +import express from 'express' +import { getDeviceTokensByUserId } from '../services/user_device_tokens' +import { + PushNotificationType, + sendMulticastPushNotifications, +} from '../utils/sendNotification' +import cors from 'cors' +import { corsConfig } from '../utils/corsConfig' +import * as jwt from 'jsonwebtoken' +import { env } from '../env' +import { Claims } from '../resolvers/types' + +interface Notification { + body: string + title?: string + data?: Record + image?: string + notificationType?: PushNotificationType +} + +export function notificationRouter() { + const router = express.Router() + + router.options('/send', cors({ ...corsConfig, maxAge: 600 })) + router.post('/send', cors(corsConfig), async (req, res) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const token = (req.cookies?.auth || req.headers?.authorization) as string + if (!token || !jwt.verify(token, env.server.jwtSecret)) { + return res.status(401).send({ errorCode: 'UNAUTHORIZED' }) + } + const claims = jwt.decode(token) as Claims + const { uid: userId } = claims + const { + body, + title, + data, + image: imageUrl, + notificationType, + } = req.body as Notification + + if (!userId || !body) { + return res.status(400).send({ errorCode: 'BAD_DATA' }) + } + + const tokens = await getDeviceTokensByUserId(userId) + if (tokens.length === 0) { + return res.status(400).send({ errorCode: 'NO_DEVICE_TOKENS' }) + } + + const message = { + notification: { + title, + body, + imageUrl, + }, + data, + tokens: tokens.map((token) => token.token), + } + + const result = await sendMulticastPushNotifications( + userId, + message, + notificationType || 'rule' + ) + if (!result) { + return res.status(400).send({ errorCode: 'SEND_NOTIFICATION_FAILED' }) + } + + res.send('OK') + }) + + return router +} diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index 0438c6921..adae47e87 100755 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -47,6 +47,7 @@ import { webhooksServiceRouter } from './routers/svc/webhooks' import { integrationsServiceRouter } from './routers/svc/integrations' import { textToSpeechRouter } from './routers/text_to_speech' import * as httpContext from 'express-http-context' +import { notificationRouter } from './routers/notification_router' const PORT = process.env.PORT || 4000 @@ -131,6 +132,7 @@ export const createApp = (): { app.use('/api/article', articleRouter()) app.use('/api/mobile-auth', mobileAuthRouter()) app.use('/api/text-to-speech', textToSpeechRouter()) + app.use('/api/notification', notificationRouter()) app.use('/svc/pubsub/content', contentServiceRouter()) app.use('/svc/pubsub/links', linkServiceRouter()) app.use('/svc/pubsub/newsletters', newsletterServiceRouter()) diff --git a/packages/api/src/utils/sendNotification.ts b/packages/api/src/utils/sendNotification.ts index b19299002..edbd86365 100644 --- a/packages/api/src/utils/sendNotification.ts +++ b/packages/api/src/utils/sendNotification.ts @@ -8,7 +8,7 @@ import { import { env } from '../env' import { analytics } from './analytics' -type PushNotificationType = 'newsletter' | 'reminder' +export type PushNotificationType = 'newsletter' | 'reminder' | 'rule' // getting credentials from App Engine initializeApp() diff --git a/packages/rule-handler/src/notification.ts b/packages/rule-handler/src/notification.ts index 31bfc20ca..287d9e886 100644 --- a/packages/rule-handler/src/notification.ts +++ b/packages/rule-handler/src/notification.ts @@ -1,97 +1,35 @@ -import { applicationDefault, initializeApp } from 'firebase-admin/app' -import { - BatchResponse, - getMessaging, - Message, - MulticastMessage, -} from 'firebase-admin/messaging' import axios from 'axios' import { getAuthToken } from './index' -export interface DeviceToken { - id: string - token: string - userId: string - createdAt: Date +interface NotificationData { + body: string + title?: string + data?: Record + image?: string + notificationType?: string } -// getting credentials from App Engine -initializeApp({ - credential: applicationDefault(), -}) - -export const getDeviceTokens = async ( +export const sendNotification = async ( userId: string, apiEndpoint: string, - jwtSecret: string -): Promise => { + jwtSecret: string, + message: string, + title?: string, + image?: string +) => { const auth = await getAuthToken(userId, jwtSecret) - const data = JSON.stringify({ - query: `query { - deviceTokens { - ... on DeviceTokensError { - errorCodes - } - ... on DeviceTokensSuccess { - deviceTokens { - id - token - createdAt - } - } - } - }`, - }) + const data: NotificationData = { + body: message, + title: title || message, + image, + notificationType: 'rule', + } - const response = await axios.post(`${apiEndpoint}/graphql`, data, { + await axios.post(`${apiEndpoint}/notification/send`, data, { headers: { Cookie: `auth=${auth};`, 'Content-Type': 'application/json', }, }) - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - return response.data.data.deviceTokens.deviceTokens as DeviceToken[] -} - -export const getBatchMessages = ( - messages: string[], - tokens: string[], - title?: string, - imageUrl?: string -): Message[] => { - const batchMessages: Message[] = [] - messages.forEach((message) => { - tokens.forEach((token) => { - batchMessages.push({ - token, - notification: { - title, - body: message, - imageUrl, - }, - }) - }) - }) - - return batchMessages -} - -export const sendPushNotification = async ( - message: Message -): Promise => { - return getMessaging().send(message) -} - -export const sendMulticastPushNotifications = async ( - message: MulticastMessage -): Promise => { - return getMessaging().sendMulticast(message) -} - -export const sendBatchPushNotifications = async ( - messages: Message[] -): Promise => { - return getMessaging().sendAll(messages) } diff --git a/packages/rule-handler/src/rule.ts b/packages/rule-handler/src/rule.ts index 990de516d..8e84f303c 100644 --- a/packages/rule-handler/src/rule.ts +++ b/packages/rule-handler/src/rule.ts @@ -1,8 +1,4 @@ -import { - getBatchMessages, - getDeviceTokens, - sendBatchPushNotifications, -} from './notification' +import { sendNotification } from './notification' import { getAuthToken, PubSubData } from './index' import axios from 'axios' import { parse, SearchParserKeyWordOffset } from 'search-query-parser' @@ -142,33 +138,10 @@ export const triggerActions = async ( case RuleActionType.MarkAsRead: continue case RuleActionType.SendNotification: - if (action.params.length === 0) { - console.log('No notification messages provided') - continue + for (const message of action.params) { + await sendNotification(userId, apiEndpoint, jwtSecret, message) } - await sendNotification(userId, action.params, apiEndpoint, jwtSecret) } } } } - -export const sendNotification = async ( - userId: string, - messages: string[], - apiEndpoint: string, - jwtSecret: string, - title?: string, - image?: string -) => { - // get device tokens by calling api - const tokens = await getDeviceTokens(userId, apiEndpoint, jwtSecret) - - const batchMessages = getBatchMessages( - messages, - tokens.map((t) => t.token), - title, - image - ) - - return sendBatchPushNotifications(batchMessages) -} From 1ef862782f384f3ada58b238fd0349d9c9834d5b Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 22 Nov 2022 12:51:59 +0800 Subject: [PATCH 4/5] Catch error from sending notification api --- packages/rule-handler/package.json | 1 - packages/rule-handler/src/notification.ts | 16 ++++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/rule-handler/package.json b/packages/rule-handler/package.json index 41f80a65e..b9eadc9c4 100644 --- a/packages/rule-handler/package.json +++ b/packages/rule-handler/package.json @@ -24,7 +24,6 @@ "@sentry/serverless": "^6.16.1", "axios": "^0.27.2", "dotenv": "^16.0.1", - "firebase-admin": "^10.0.2", "jsonwebtoken": "^8.5.1", "search-query-parser": "^1.6.0" } diff --git a/packages/rule-handler/src/notification.ts b/packages/rule-handler/src/notification.ts index 287d9e886..6a12a94a2 100644 --- a/packages/rule-handler/src/notification.ts +++ b/packages/rule-handler/src/notification.ts @@ -26,10 +26,14 @@ export const sendNotification = async ( notificationType: 'rule', } - await axios.post(`${apiEndpoint}/notification/send`, data, { - headers: { - Cookie: `auth=${auth};`, - 'Content-Type': 'application/json', - }, - }) + try { + await axios.post(`${apiEndpoint}/notification/send`, data, { + headers: { + Cookie: `auth=${auth};`, + 'Content-Type': 'application/json', + }, + }) + } catch (e) { + console.error(e) + } } From ba730bdc3e7fc983d24a5d3b8eb52946f1d076c8 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 22 Nov 2022 13:00:01 +0800 Subject: [PATCH 5/5] Use default application credential --- packages/api/src/utils/sendNotification.ts | 6 ++++-- packages/rule-handler/src/index.ts | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/api/src/utils/sendNotification.ts b/packages/api/src/utils/sendNotification.ts index edbd86365..0421b5688 100644 --- a/packages/api/src/utils/sendNotification.ts +++ b/packages/api/src/utils/sendNotification.ts @@ -1,4 +1,4 @@ -import { initializeApp } from 'firebase-admin/app' +import { applicationDefault, initializeApp } from 'firebase-admin/app' import { BatchResponse, getMessaging, @@ -11,7 +11,9 @@ import { analytics } from './analytics' export type PushNotificationType = 'newsletter' | 'reminder' | 'rule' // getting credentials from App Engine -initializeApp() +initializeApp({ + credential: applicationDefault(), +}) export const sendPushNotification = async ( userId: string, diff --git a/packages/rule-handler/src/index.ts b/packages/rule-handler/src/index.ts index 16e800d2f..bdfffe4ae 100644 --- a/packages/rule-handler/src/index.ts +++ b/packages/rule-handler/src/index.ts @@ -107,6 +107,11 @@ export const ruleHandler = Sentry.GCPFunction.wrapHttpFunction( // get rules by calling api const rules = await getEnabledRules(userId, apiEndpoint, jwtSecret) + if (!rules || rules.length === 0) { + console.log('No rules found') + res.status(200).send('No Rules') + return + } await triggerActions(userId, rules, data, apiEndpoint, jwtSecret)