Merge pull request #1436 from omnivore-app/rule-send-notification
Add title and image url in notification
This commit is contained in:
73
packages/api/src/routers/notification_router.ts
Normal file
73
packages/api/src/routers/notification_router.ts
Normal 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
|
||||
}
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
39
packages/rule-handler/src/notification.ts
Normal file
39
packages/rule-handler/src/notification.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user