Merge pull request #1436 from omnivore-app/rule-send-notification

Add title and image url in notification
This commit is contained in:
Hongbo Wu
2022-11-22 13:13:21 +08:00
committed by GitHub
9 changed files with 199 additions and 146 deletions

View File

@ -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<string, string>
image?: string
notificationType?: PushNotificationType
}
export function notificationRouter() {
const router = express.Router()
router.options('/send', cors<express.Request>({ ...corsConfig, maxAge: 600 }))
router.post('/send', cors<express.Request>(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
}

View File

@ -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())

View File

@ -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
}

View File

@ -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,

View File

@ -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"
}
}

View File

@ -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)

View File

@ -0,0 +1,39 @@
import axios from 'axios'
import { getAuthToken } from './index'
interface NotificationData {
body: string
title?: string
data?: Record<string, string>
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)
}
}

View File

@ -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)
}

View File

@ -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<DeviceToken[]> => {
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<string | undefined> => {
return getMessaging().send(message)
}
export const sendMulticastPushNotifications = async (
message: MulticastMessage
): Promise<BatchResponse | undefined> => {
return getMessaging().sendMulticast(message)
}
export const sendBatchPushNotifications = async (
messages: Message[]
): Promise<BatchResponse | undefined> => {
const res = await getMessaging().sendAll(messages)
console.debug('res', res)
return res
}