Merge pull request #2515 from omnivore-app/fix/push-notification

fix/push notification
This commit is contained in:
Hongbo Wu 2023-07-26 12:36:13 +08:00 committed by GitHub
commit 0a227decdd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 241 additions and 106 deletions

View file

@ -117,6 +117,7 @@
"@types/highlightjs": "^9.12.2",
"@types/intercom-client": "^2.11.8",
"@types/jsonwebtoken": "^8.5.0",
"@types/lodash.debounce": "^4.0.6",
"@types/luxon": "^1.25.0",
"@types/mocha": "^8.2.2",
"@types/nanoid": "^3.0.0",

View file

@ -16,6 +16,11 @@ export enum RuleActionType {
SendNotification = 'SEND_NOTIFICATION',
}
export enum RuleEventType {
PageCreated = 'PAGE_CREATED',
PageUpdated = 'PAGE_UPDATED',
}
export interface RuleAction {
type: RuleActionType
params: string[]
@ -42,6 +47,9 @@ export class Rule {
@Column('text', { nullable: true })
description?: string | null
@Column('text', { array: true })
eventTypes!: RuleEventType[]
@Column('boolean', { default: true })
enabled!: boolean

View file

@ -2140,6 +2140,7 @@ export type Rule = {
actions: Array<RuleAction>;
createdAt: Scalars['Date'];
enabled: Scalars['Boolean'];
eventTypes: Array<RuleEventType>;
filter: Scalars['String'];
id: Scalars['ID'];
name: Scalars['String'];
@ -2164,6 +2165,11 @@ export enum RuleActionType {
SendNotification = 'SEND_NOTIFICATION'
}
export enum RuleEventType {
PageCreated = 'PAGE_CREATED',
PageUpdated = 'PAGE_UPDATED'
}
export type RulesError = {
__typename?: 'RulesError';
errorCodes: Array<RulesErrorCode>;
@ -2529,6 +2535,7 @@ export type SetRuleInput = {
actions: Array<RuleActionInput>;
description?: InputMaybe<Scalars['String']>;
enabled: Scalars['Boolean'];
eventTypes: Array<RuleEventType>;
filter: Scalars['String'];
id?: InputMaybe<Scalars['ID']>;
name: Scalars['String'];
@ -3643,6 +3650,7 @@ export type ResolversTypes = {
RuleAction: ResolverTypeWrapper<RuleAction>;
RuleActionInput: RuleActionInput;
RuleActionType: RuleActionType;
RuleEventType: RuleEventType;
RulesError: ResolverTypeWrapper<RulesError>;
RulesErrorCode: RulesErrorCode;
RulesResult: ResolversTypes['RulesError'] | ResolversTypes['RulesSuccess'];
@ -5473,6 +5481,7 @@ export type RuleResolvers<ContextType = ResolverContext, ParentType extends Reso
actions?: Resolver<Array<ResolversTypes['RuleAction']>, ParentType, ContextType>;
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
enabled?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
eventTypes?: Resolver<Array<ResolversTypes['RuleEventType']>, ParentType, ContextType>;
filter?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;

View file

@ -1528,6 +1528,7 @@ type Rule {
actions: [RuleAction!]!
createdAt: Date!
enabled: Boolean!
eventTypes: [RuleEventType!]!
filter: String!
id: ID!
name: String!
@ -1551,6 +1552,11 @@ enum RuleActionType {
SEND_NOTIFICATION
}
enum RuleEventType {
PAGE_CREATED
PAGE_UPDATED
}
type RulesError {
errorCodes: [RulesErrorCode!]!
}
@ -1889,6 +1895,7 @@ input SetRuleInput {
actions: [RuleActionInput!]!
description: String
enabled: Boolean!
eventTypes: [RuleEventType!]!
filter: String!
id: ID
name: String!

View file

@ -1,14 +1,15 @@
import cors from 'cors'
import express from 'express'
import * as jwt from 'jsonwebtoken'
import debounce from 'lodash.debounce'
import { env } from '../env'
import { Claims } from '../resolvers/types'
import { getDeviceTokensByUserId } from '../services/user_device_tokens'
import { corsConfig } from '../utils/corsConfig'
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
@ -57,14 +58,17 @@ export function notificationRouter() {
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' })
}
// Debounce the sendMulticastPushNotifications function with a delay of 1 minute
debounce(async () => {
const result = await sendMulticastPushNotifications(
userId,
message,
notificationType || 'rule'
)
if (!result) {
return res.status(400).send({ errorCode: 'SEND_NOTIFICATION_FAILED' })
}
}, 60 * 1000)
res.send('OK')
})

View file

@ -1,17 +1,19 @@
import express from 'express'
import { analytics } from '../../utils/analytics'
import { initModels } from '../../server'
import { kx } from '../../datalayer/knex_config'
import { setClaims } from '../../datalayer/helpers'
import { sendEmail } from '../../utils/sendEmail'
import { env, homePageURL } from '../../env'
import { MulticastMessage } from 'firebase-admin/messaging'
import { setClaims } from '../../datalayer/helpers'
import { kx } from '../../datalayer/knex_config'
import { createPubSubClient } from '../../datalayer/pubsub'
import { updatePage } from '../../elastic/pages'
import { UserDeviceToken } from '../../entity/user_device_tokens'
import { env, homePageURL } from '../../env'
import { ContentReader } from '../../generated/graphql'
import { DataModels } from '../../resolvers/types'
import { updatePage } from '../../elastic/pages'
import { createPubSubClient } from '../../datalayer/pubsub'
import { initModels } from '../../server'
import { getPagesWithReminder, PageReminder } from '../../services/reminders'
import { getDeviceTokensByUserId } from '../../services/user_device_tokens'
import { analytics } from '../../utils/analytics'
import { sendEmail } from '../../utils/sendEmail'
import { sendMulticastPushNotifications } from '../../utils/sendNotification'
interface PageToNotify {
title: string
@ -106,19 +108,19 @@ export function remindersServiceRouter() {
to: user.email,
})
// // send push notifications
// const deviceTokens = await getDeviceTokensByUserId(userId)
// if (deviceTokens && deviceTokens.length > 0) {
// const message = messageForPages(pageReminders, deviceTokens)
// await sendMulticastPushNotifications(userId, message, 'reminder')
// }
//
// if (!deviceTokens) {
// console.log('Device tokens not set:', userId)
//
// res.status(400).send('Device token Not Found')
// return
// }
// send push notifications
const deviceTokens = await getDeviceTokensByUserId(userId)
if (deviceTokens && deviceTokens.length > 0) {
const message = messageForPages(pageReminders, deviceTokens)
await sendMulticastPushNotifications(userId, message, 'reminder')
}
if (!deviceTokens) {
console.log('Device tokens not set:', userId)
res.status(400).send('Device token Not Found')
return
}
}
await updateRemindersStatus(models, userId, pagesToUnarchive, remindAt)

View file

@ -2066,6 +2066,7 @@ const schema = gql`
enabled: Boolean!
createdAt: Date!
updatedAt: Date!
eventTypes: [RuleEventType!]!
}
type RuleAction {
@ -2089,6 +2090,11 @@ const schema = gql`
BAD_REQUEST
}
enum RuleEventType {
PAGE_CREATED
PAGE_UPDATED
}
input SetRuleInput {
id: ID
name: String!
@ -2096,6 +2102,7 @@ const schema = gql`
filter: String!
actions: [RuleActionInput!]!
enabled: Boolean!
eventTypes: [RuleEventType!]!
}
input RuleActionInput {

View file

@ -9,9 +9,11 @@ import { ContentReader } from '../generated/graphql'
import { analytics } from '../utils/analytics'
import { isBase64Image } from '../utils/helpers'
import { fetchFavicon } from '../utils/parser'
import { sendMulticastPushNotifications } from '../utils/sendNotification'
import { addLabelToPage } from './labels'
import { SaveContext, saveEmail, SaveEmailInput } from './save_email'
import { saveSubscription } from './subscriptions'
import { getDeviceTokensByUserId } from './user_device_tokens'
export interface NewsletterMessage {
email: string
@ -93,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

@ -48,6 +48,7 @@ describe('Rules Resolver', () => {
}`
)}],
enabled: ${enabled}
eventTypes: [PAGE_CREATED, PAGE_UPDATED]
}) {
... on SetRuleSuccess {
rule {
@ -116,6 +117,7 @@ describe('Rules Resolver', () => {
enabled
createdAt
updatedAt
eventTypes
}
}
... on RulesError {

View file

@ -0,0 +1,21 @@
-- Type: DO
-- Name: add_event_types_to_rules
-- Description: Add event_types field to rules table
BEGIN;
ALTER TABLE omnivore.rules ADD COLUMN event_types text[] NOT NULL DEFAULT '{PAGE_CREATED,PAGE_UPDATED}';
-- Add event_types to existing rules
UPDATE omnivore.rules
SET
event_types = '{PAGE_CREATED}', filter = REPLACE (filter, 'event:created', '')
WHERE
filter LIKE '%event:created%';
UPDATE omnivore.rules
SET
event_types = '{PAGE_UPDATED}', filter = REPLACE (filter, 'event:updated', '')
WHERE
filter LIKE '%event:updated%';
COMMIT;

View file

@ -0,0 +1,20 @@
-- Type: UNDO
-- Name: add_event_types_to_rules
-- Description: Add event_types field to rules table
BEGIN;
UPDATE omnivore.rules
SET
filter = CONCAT ('event:created ', filter)
WHERE
'PAGE_CREATED' = ALL (event_types);
UPDATE omnivore.rules
SET
filter = CONCAT ('event:updated ', filter)
WHERE
'PAGE_UPDATED' = ALL (event_types);
ALTER TABLE omnivore.rules DROP COLUMN event_types;
COMMIT;

View file

@ -1,9 +1,9 @@
import * as Sentry from '@sentry/serverless'
import express, { Request, Response } from 'express'
import * as dotenv from 'dotenv'
import { getEnabledRules, triggerActions } from './rule'
import { promisify } from 'util'
import express, { Request, Response } from 'express'
import * as jwt from 'jsonwebtoken'
import { promisify } from 'util'
import { getEnabledRules, RuleEventType, triggerActions } from './rule'
const signToken = promisify(jwt.sign)
@ -76,6 +76,10 @@ export const getAuthToken = async (
return auth as string
}
const ruleEventType = (eventType: string) => {
return `PAGE_${eventType.toUpperCase()}` as RuleEventType
}
export const ruleHandler = Sentry.GCPFunction.wrapHttpFunction(
async (req: Request, res: Response) => {
const apiEndpoint = process.env.REST_BACKEND_ENDPOINT
@ -134,7 +138,7 @@ export const ruleHandler = Sentry.GCPFunction.wrapHttpFunction(
data,
apiEndpoint,
jwtSecret,
eventType
ruleEventType(eventType)
)
if (triggeredActions.length === 0) {
console.log('No actions triggered')

View file

@ -2,6 +2,7 @@ import axios, { AxiosResponse } from 'axios'
import { filterPage } from './filter'
import { getAuthToken, PubSubData } from './index'
import { setLabels } from './label'
import { NotificationData, sendNotification } from './notification'
import { archivePage, markPageAsRead } from './page'
export enum RuleActionType {
@ -16,6 +17,11 @@ export interface RuleAction {
params: string[]
}
export enum RuleEventType {
PageCreated = 'PAGE_CREATED',
PageUpdated = 'PAGE_UPDATED',
}
export interface Rule {
id: string
userId: string
@ -26,10 +32,9 @@ export interface Rule {
enabled: boolean
createdAt: Date
updatedAt: Date
eventTypes: RuleEventType[]
}
const EVENT_FILTERS = ['event:created', 'event:updated']
export const getEnabledRules = async (
userId: string,
apiEndpoint: string,
@ -52,6 +57,7 @@ export const getEnabledRules = async (
type
params
}
eventTypes
}
}
}
@ -75,33 +81,22 @@ export const triggerActions = async (
data: PubSubData,
apiEndpoint: string,
jwtSecret: string,
eventType: string
eventType: RuleEventType
) => {
const authToken = await getAuthToken(userId, jwtSecret)
const actionPromises: Promise<AxiosResponse<any, any> | undefined>[] = []
for (const rule of rules) {
let filter = rule.filter
const filters = filter.split(' ')
// Check if the rule is enabled for the event type
const eventFilterIndex = filters.findIndex((f) => EVENT_FILTERS.includes(f))
if (eventFilterIndex !== -1) {
const eventFilter = filters[eventFilterIndex]
if (eventFilter !== `event:${eventType}`.toLowerCase()) {
continue
}
// Remove the event filter from the filter string
filters.splice(eventFilterIndex, 1)
filter = filters.join(' ')
if (!rule.eventTypes.includes(eventType)) {
continue
}
const filteredPage = await filterPage(
userId,
apiEndpoint,
authToken,
filter,
rule.filter,
data.id
)
if (!filteredPage) {
@ -140,25 +135,25 @@ export const triggerActions = async (
filteredPage.readingProgressPercent < 100 &&
actionPromises.push(markPageAsRead(apiEndpoint, authToken, data.id))
)
// case RuleActionType.SendNotification: {
// const data: NotificationData = {
// title: 'New page added to your library',
// body: filteredPage.title,
// image: filteredPage.image || undefined,
// }
case RuleActionType.SendNotification: {
const data: NotificationData = {
title: 'New page added to your library',
body: filteredPage.title,
image: filteredPage.image || undefined,
}
// const params = action.params
// if (params.length > 0) {
// const param = JSON.parse(params[0]) as NotificationData
// data.body = param.body || data.body
// data.title = param.title || data.title
// data.image = param.image || data.image
// }
const params = action.params
if (params.length > 0) {
const param = JSON.parse(params[0]) as NotificationData
data.body = param.body || data.body
data.title = param.title || data.title
data.image = param.image || data.image
}
// return actionPromises.push(
// sendNotification(apiEndpoint, authToken, data)
// )
// }
return actionPromises.push(
sendNotification(apiEndpoint, authToken, data)
)
}
}
})
}

View file

@ -1,6 +1,6 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
import { Rule, RuleAction } from '../queries/useGetRulesQuery'
import { Rule, RuleAction, RuleEventType } from '../queries/useGetRulesQuery'
export type SetRuleInput = {
id?: string
@ -8,6 +8,7 @@ export type SetRuleInput = {
filter: string
actions: RuleAction[]
enabled: boolean
eventTypes: RuleEventType[]
}
type SetRuleResult = {
@ -35,6 +36,7 @@ export async function setRuleMutation(
params
}
enabled
eventTypes
}
}
... on SetRuleError {

View file

@ -11,7 +11,12 @@ export enum RuleActionType {
AddLabel = 'ADD_LABEL',
Archive = 'ARCHIVE',
MarkAsRead = 'MARK_AS_READ',
// SendNotification = 'SEND_NOTIFICATION',
SendNotification = 'SEND_NOTIFICATION',
}
export enum RuleEventType {
PAGE_CREATED = 'PAGE_CREATED',
PAGE_UPDATED = 'PAGE_UPDATED',
}
export interface Rule {
@ -22,6 +27,7 @@ export interface Rule {
enabled: boolean
createdAt: Date
updatedAt: Date
eventTypes: RuleEventType[]
}
interface RulesQueryResponse {
@ -54,6 +60,7 @@ export function useGetRulesQuery(): RulesQueryResponse {
enabled
createdAt
updatedAt
eventTypes
}
}
... on RulesError {

View file

@ -1,26 +1,23 @@
import { Button, Form, Input, Modal, Select, Space, Table, Tag } from 'antd'
// import 'antd/dist/antd.dark.css'
import 'antd/dist/antd.compact.css'
import { useCallback, useMemo, useState } from 'react'
import { Toaster } from 'react-hot-toast'
import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers'
import { applyStoredTheme } from '../../lib/themeUpdater'
import { Box, HStack } from '../../components/elements/LayoutPrimitives'
import { SettingsLayout } from '../../components/templates/SettingsLayout'
import { Label } from '../../lib/networking/fragments/labelFragment'
import { deleteRuleMutation } from '../../lib/networking/mutations/deleteRuleMutation'
import { setRuleMutation } from '../../lib/networking/mutations/setRuleMutation'
import { useGetLabelsQuery } from '../../lib/networking/queries/useGetLabelsQuery'
import {
Rule,
RuleAction,
RuleActionType,
RuleEventType,
useGetRulesQuery,
} from '../../lib/networking/queries/useGetRulesQuery'
import { SettingsLayout } from '../../components/templates/SettingsLayout'
import { Button, Space, Table, Form, Input, Modal, Tag, Select } from 'antd'
// import 'antd/dist/antd.dark.css'
import 'antd/dist/antd.compact.css'
import { Box, HStack } from '../../components/elements/LayoutPrimitives'
import { useGetLabelsQuery } from '../../lib/networking/queries/useGetLabelsQuery'
import { Label } from '../../lib/networking/fragments/labelFragment'
import { setRuleMutation } from '../../lib/networking/mutations/setRuleMutation'
import { deleteRuleMutation } from '../../lib/networking/mutations/deleteRuleMutation'
import { applyStoredTheme } from '../../lib/themeUpdater'
import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers'
type CreateRuleModalProps = {
isModalOpen: boolean
@ -34,11 +31,13 @@ const CreateRuleModal = (props: CreateRuleModalProps): JSX.Element => {
const onOk = async (values: any) => {
const name = form.getFieldValue('name')
const filter = form.getFieldValue('filter')
const eventTypes = form.getFieldValue('eventTypes')
await setRuleMutation({
name,
filter,
actions: [],
enabled: true,
eventTypes,
})
form.resetFields()
props.setIsModalOpen(false)
@ -55,7 +54,7 @@ const CreateRuleModal = (props: CreateRuleModalProps): JSX.Element => {
<Modal
title="Create Rule"
open={props.isModalOpen}
onOk={onOk}
onOk={form.submit}
onCancel={onCancel}
destroyOnClose={true}
>
@ -64,7 +63,7 @@ const CreateRuleModal = (props: CreateRuleModalProps): JSX.Element => {
name="createRule"
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
// onFinish={onFinish}
onFinish={onOk}
// onFinishFailed={onFinishFailed}
autoComplete="off"
>
@ -83,6 +82,32 @@ const CreateRuleModal = (props: CreateRuleModalProps): JSX.Element => {
>
<Input />
</Form.Item>
<Form.Item
label="When"
name="eventTypes"
rules={[
{
required: true,
message: 'Please select when the rule will be triggered',
},
]}
>
<Select
mode="multiple"
allowClear
placeholder="Please select when the rule will be triggered"
>
{Object.keys(RuleEventType).map((key: string, index: number) => {
const value = Object.values(RuleEventType)[index]
return (
<Select.Option key={key} value={value}>
{key}
</Select.Option>
)
})}
</Select>
</Form.Item>
</Form>
</Modal>
)
@ -115,6 +140,7 @@ const CreateActionModal = (props: CreateActionModalProps): JSX.Element => {
params: params,
},
],
eventTypes: props.rule.eventTypes,
})
form.resetFields()
props.setIsModalOpen(false)
@ -131,7 +157,7 @@ const CreateActionModal = (props: CreateActionModalProps): JSX.Element => {
<Modal
title="Create Action"
open={props.rule != undefined}
onOk={onOk}
onOk={form.submit}
onCancel={() => {
form.resetFields()
props.setIsModalOpen(false)
@ -142,6 +168,7 @@ const CreateActionModal = (props: CreateActionModalProps): JSX.Element => {
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
autoComplete="off"
onFinish={onOk}
>
<Form.Item
label="Action"
@ -203,6 +230,7 @@ export default function Rules(): JSX.Element {
name: rule.name,
filter: rule.filter,
actions: rule.actions,
eventTypes: rule.eventTypes,
}
})
}, [rules])
@ -250,6 +278,20 @@ export default function Rules(): JSX.Element {
dataIndex: 'filter',
key: 'filter',
},
{
title: 'When',
render: (text: string, row: { eventTypes: RuleEventType[] }) => (
<>
{row.eventTypes.map((eventType: RuleEventType, index: number) => {
return (
<Tag color={'geekblue'} key={index}>
{eventType}
</Tag>
)
})}
</>
),
},
{
title: 'Actions',
render: (text: string, row: { actions: RuleAction[] }) => (
@ -324,7 +366,9 @@ export default function Rules(): JSX.Element {
revalidate={revalidate}
/>
<Box css={{ pt: '44px', px: '10%', '@smDown': { px: '0' } }}>
<Box
css={{ pt: '44px', px: '10%', '@smDown': { px: '0' }, width: '100%' }}
>
<HStack css={{ py: '16px' }} distribution="end">
<Button
onClick={() => {