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/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/api/src/utils/sendNotification.ts b/packages/api/src/utils/sendNotification.ts index b19299002..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, @@ -8,10 +8,12 @@ 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() +initializeApp({ + credential: applicationDefault(), +}) export const sendPushNotification = async ( userId: string, diff --git a/packages/rule-handler/package.json b/packages/rule-handler/package.json index 6c17ca61b..b9eadc9c4 100644 --- a/packages/rule-handler/package.json +++ b/packages/rule-handler/package.json @@ -24,7 +24,7 @@ "@sentry/serverless": "^6.16.1", "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/index.ts b/packages/rule-handler/src/index.ts index 6914ce6dd..bdfffe4ae 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 { @@ -106,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) diff --git a/packages/rule-handler/src/notification.ts b/packages/rule-handler/src/notification.ts new file mode 100644 index 000000000..6a12a94a2 --- /dev/null +++ b/packages/rule-handler/src/notification.ts @@ -0,0 +1,39 @@ +import axios from 'axios' +import { getAuthToken } from './index' + +interface NotificationData { + body: string + title?: string + data?: Record + image?: string + notificationType?: string +} + +export const sendNotification = async ( + userId: string, + apiEndpoint: string, + jwtSecret: string, + message: string, + title?: string, + image?: string +) => { + const auth = await getAuthToken(userId, jwtSecret) + + const data: NotificationData = { + body: message, + title: title || message, + image, + notificationType: 'rule', + } + + try { + await axios.post(`${apiEndpoint}/notification/send`, data, { + headers: { + Cookie: `auth=${auth};`, + 'Content-Type': 'application/json', + }, + }) + } catch (e) { + console.error(e) + } +} diff --git a/packages/rule-handler/src/rule.ts b/packages/rule-handler/src/rule.ts index 7343fcdfc..8e84f303c 100644 --- a/packages/rule-handler/src/rule.ts +++ b/packages/rule-handler/src/rule.ts @@ -1,10 +1,7 @@ -import { - getBatchMessages, - getDeviceTokens, - sendBatchPushNotifications, -} from './sendNotification' +import { 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 +27,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 +127,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 } @@ -90,29 +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 -) => { - // get device tokens by calling api - const tokens = await getDeviceTokens(userId, apiEndpoint, jwtSecret) - - const batchMessages = getBatchMessages( - messages, - tokens.map((t) => t.token) - ) - - return sendBatchPushNotifications(batchMessages) -} diff --git a/packages/rule-handler/src/sendNotification.ts b/packages/rule-handler/src/sendNotification.ts deleted file mode 100644 index 684f858e6..000000000 --- a/packages/rule-handler/src/sendNotification.ts +++ /dev/null @@ -1,96 +0,0 @@ -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 -} - -// getting credentials from App Engine -initializeApp({ - credential: applicationDefault(), -}) - -export const getDeviceTokens = async ( - userId: string, - apiEndpoint: string, - jwtSecret: string -): Promise => { - const auth = await getAuthToken(userId, jwtSecret) - - const data = JSON.stringify({ - query: `query { - deviceTokens { - ... on DeviceTokensError { - errorCodes - } - ... on DeviceTokensSuccess { - deviceTokens { - id - token - createdAt - } - } - } - }`, - }) - - const response = await axios.post(`${apiEndpoint}/graphql`, 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[] -): Message[] => { - const batchMessages: Message[] = [] - messages.forEach((message) => { - tokens.forEach((token) => { - batchMessages.push({ - token, - notification: { - body: message, - }, - }) - }) - }) - - 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 => { - const res = await getMessaging().sendAll(messages) - console.debug('res', res) - - return res -}