diff --git a/packages/api/package.json b/packages/api/package.json index 671a445cd..7d96bda57 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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", diff --git a/packages/api/src/entity/rule.ts b/packages/api/src/entity/rule.ts index 16ce007e1..04d53ab33 100644 --- a/packages/api/src/entity/rule.ts +++ b/packages/api/src/entity/rule.ts @@ -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 diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 52ebd6f6f..07daba72c 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -2140,6 +2140,7 @@ export type Rule = { actions: Array; createdAt: Scalars['Date']; enabled: Scalars['Boolean']; + eventTypes: Array; 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; @@ -2529,6 +2535,7 @@ export type SetRuleInput = { actions: Array; description?: InputMaybe; enabled: Scalars['Boolean']; + eventTypes: Array; filter: Scalars['String']; id?: InputMaybe; name: Scalars['String']; @@ -3643,6 +3650,7 @@ export type ResolversTypes = { RuleAction: ResolverTypeWrapper; RuleActionInput: RuleActionInput; RuleActionType: RuleActionType; + RuleEventType: RuleEventType; RulesError: ResolverTypeWrapper; RulesErrorCode: RulesErrorCode; RulesResult: ResolversTypes['RulesError'] | ResolversTypes['RulesSuccess']; @@ -5473,6 +5481,7 @@ export type RuleResolvers, ParentType, ContextType>; createdAt?: Resolver; enabled?: Resolver; + eventTypes?: Resolver, ParentType, ContextType>; filter?: Resolver; id?: Resolver; name?: Resolver; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 9e8474872..b001391ca 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -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! diff --git a/packages/api/src/routers/notification_router.ts b/packages/api/src/routers/notification_router.ts index 14e44303d..bab1da46b 100644 --- a/packages/api/src/routers/notification_router.ts +++ b/packages/api/src/routers/notification_router.ts @@ -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') }) diff --git a/packages/api/src/routers/svc/reminders.ts b/packages/api/src/routers/svc/reminders.ts index 5da5dd4da..85f9ce6ea 100644 --- a/packages/api/src/routers/svc/reminders.ts +++ b/packages/api/src/routers/svc/reminders.ts @@ -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) diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 7d900e0c8..74b3f283b 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -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 { diff --git a/packages/api/src/services/save_newsletter_email.ts b/packages/api/src/services/save_newsletter_email.ts index b8e6ee8bf..4f3b3f603 100644 --- a/packages/api/src/services/save_newsletter_email.ts +++ b/packages/api/src/services/save_newsletter_email.ts @@ -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 } diff --git a/packages/api/test/resolvers/rules.test.ts b/packages/api/test/resolvers/rules.test.ts index d593a034f..5a55195bb 100644 --- a/packages/api/test/resolvers/rules.test.ts +++ b/packages/api/test/resolvers/rules.test.ts @@ -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 { diff --git a/packages/db/migrations/0117.do.add_event_types_to_rules.sql b/packages/db/migrations/0117.do.add_event_types_to_rules.sql new file mode 100755 index 000000000..b96e52451 --- /dev/null +++ b/packages/db/migrations/0117.do.add_event_types_to_rules.sql @@ -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; diff --git a/packages/db/migrations/0117.undo.add_event_types_to_rules.sql b/packages/db/migrations/0117.undo.add_event_types_to_rules.sql new file mode 100755 index 000000000..4c8204de5 --- /dev/null +++ b/packages/db/migrations/0117.undo.add_event_types_to_rules.sql @@ -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; diff --git a/packages/rule-handler/src/index.ts b/packages/rule-handler/src/index.ts index 9bc6b2e08..e47bba65b 100644 --- a/packages/rule-handler/src/index.ts +++ b/packages/rule-handler/src/index.ts @@ -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') diff --git a/packages/rule-handler/src/rule.ts b/packages/rule-handler/src/rule.ts index f7a9b799c..f0c8a9b79 100644 --- a/packages/rule-handler/src/rule.ts +++ b/packages/rule-handler/src/rule.ts @@ -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 | 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) + ) + } } }) } diff --git a/packages/web/lib/networking/mutations/setRuleMutation.ts b/packages/web/lib/networking/mutations/setRuleMutation.ts index bd76a5e7d..4336f03a9 100644 --- a/packages/web/lib/networking/mutations/setRuleMutation.ts +++ b/packages/web/lib/networking/mutations/setRuleMutation.ts @@ -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 { diff --git a/packages/web/lib/networking/queries/useGetRulesQuery.tsx b/packages/web/lib/networking/queries/useGetRulesQuery.tsx index ffd1cbab5..3738e7461 100644 --- a/packages/web/lib/networking/queries/useGetRulesQuery.tsx +++ b/packages/web/lib/networking/queries/useGetRulesQuery.tsx @@ -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 { diff --git a/packages/web/pages/settings/rules.tsx b/packages/web/pages/settings/rules.tsx index 9126461e8..5da8d0315 100644 --- a/packages/web/pages/settings/rules.tsx +++ b/packages/web/pages/settings/rules.tsx @@ -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 => { @@ -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 => { > + + + + ) @@ -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 => { { 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} > ( + <> + {row.eventTypes.map((eventType: RuleEventType, index: number) => { + return ( + + {eventType} + + ) + })} + + ), + }, { title: 'Actions', render: (text: string, row: { actions: RuleAction[] }) => ( @@ -324,7 +366,9 @@ export default function Rules(): JSX.Element { revalidate={revalidate} /> - +