From 6e2344cb602c9a63e8b0ceafa3aeab09703722cb Mon Sep 17 00:00:00 2001 From: Estela Silva Date: Wed, 31 Jul 2024 18:44:06 -0300 Subject: [PATCH 01/15] updated models and types files to add aplication triggers --- src/const/enumTypes.ts | 2 ++ src/models/customNotification.model.ts | 10 +++++++++- src/schema/types/customNotification.type.ts | 4 ++++ src/schema/types/resource.type.ts | 22 ++++++++++++++++++++- 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/const/enumTypes.ts b/src/const/enumTypes.ts index 65ee1d0ca..1a2947dff 100644 --- a/src/const/enumTypes.ts +++ b/src/const/enumTypes.ts @@ -99,6 +99,7 @@ export const customNotificationStatus = { export const customNotificationRecipientsType = { email: 'email', userField: 'userField', + emailField: 'userField', distributionList: 'distributionList', }; @@ -107,6 +108,7 @@ export const customNotificationRecipientsType = { */ export const customNotificationType = { email: 'email', + notification: 'notification', }; /** diff --git a/src/models/customNotification.model.ts b/src/models/customNotification.model.ts index c834ca854..b703fb984 100644 --- a/src/models/customNotification.model.ts +++ b/src/models/customNotification.model.ts @@ -61,6 +61,10 @@ export const customNotificationSchema = new Schema( default: customNotificationLastExecutionStatus.pending, required: true, }, + onRecordCreation: Boolean, + onRecordUpdate: Boolean, + applicationTrigger: Boolean, + filter: mongoose.Schema.Types.Mixed, }, { timestamps: { createdAt: 'createdAt', updatedAt: 'modifiedAt' }, @@ -72,7 +76,7 @@ export interface CustomNotification extends Document { kind: 'CustomNotification'; name: string; description: string; - schedule: string; + schedule?: string; notificationType: string; resource: mongoose.Types.ObjectId; layout: mongoose.Types.ObjectId; @@ -84,4 +88,8 @@ export interface CustomNotification extends Document { lastExecutionStatus: string; createdAt?: Date; modifiedAt?: Date; + onRecordCreation?: boolean; + onRecordUpdate?: boolean; // record deletion counts as an update + applicationTrigger?: boolean; + filter?: any; } diff --git a/src/schema/types/customNotification.type.ts b/src/schema/types/customNotification.type.ts index 3188cfb47..709438845 100644 --- a/src/schema/types/customNotification.type.ts +++ b/src/schema/types/customNotification.type.ts @@ -33,6 +33,10 @@ export const CustomNotificationType = new GraphQLObjectType({ modifiedAt: { type: GraphQLString }, status: { type: GraphQLString }, recipientsType: { type: GraphQLString }, + onRecordCreation: { type: GraphQLBoolean }, + onRecordUpdate: { type: GraphQLBoolean }, + applicationTrigger: { type: GraphQLBoolean }, + filter: { type: GraphQLJSON }, }), }); diff --git a/src/schema/types/resource.type.ts b/src/schema/types/resource.type.ts index 944510141..dc4401832 100644 --- a/src/schema/types/resource.type.ts +++ b/src/schema/types/resource.type.ts @@ -15,8 +15,9 @@ import { LayoutConnectionType, AggregationConnectionType, FieldMetaDataType, + CustomNotificationType, } from '.'; -import { Form, Record } from '@models'; +import { Application, Form, Record } from '@models'; import { AppAbility } from '@security/defineUserAbility'; import extendAbilityForRecords, { userHasRoleFor, @@ -252,6 +253,25 @@ export const ResourceType = new GraphQLObjectType({ return count; }, }, + customNotifications: { + type: new GraphQLList(CustomNotificationType), + args: { + application: { type: GraphQLID }, + }, + async resolve(parent, args) { + if (args.application) { + const application = await Application.findById( + args.application + ).populate({ + path: 'customNotifications', + model: 'CustomNotification', + match: { applicationTrigger: true }, + }); + return application?.customNotifications ?? []; + } + return []; + }, + }, fields: { type: GraphQLJSON }, canCreateRecords: { type: GraphQLBoolean, From db4cb3f82ac5123c4260e0119d3eedf731e905e9 Mon Sep 17 00:00:00 2001 From: Estela Silva Date: Thu, 1 Aug 2024 16:48:24 -0300 Subject: [PATCH 02/15] updated resource model and edit Resource mutation to save triggers filters --- src/models/customNotification.model.ts | 2 -- src/models/resource.model.ts | 5 +++++ src/schema/mutation/editResource.mutation.ts | 15 +++++++++++++++ src/schema/types/customNotification.type.ts | 1 - src/schema/types/resource.type.ts | 1 + 5 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/models/customNotification.model.ts b/src/models/customNotification.model.ts index b703fb984..080dc884d 100644 --- a/src/models/customNotification.model.ts +++ b/src/models/customNotification.model.ts @@ -64,7 +64,6 @@ export const customNotificationSchema = new Schema( onRecordCreation: Boolean, onRecordUpdate: Boolean, applicationTrigger: Boolean, - filter: mongoose.Schema.Types.Mixed, }, { timestamps: { createdAt: 'createdAt', updatedAt: 'modifiedAt' }, @@ -91,5 +90,4 @@ export interface CustomNotification extends Document { onRecordCreation?: boolean; onRecordUpdate?: boolean; // record deletion counts as an update applicationTrigger?: boolean; - filter?: any; } diff --git a/src/models/resource.model.ts b/src/models/resource.model.ts index 83ecc3bd0..fa78eb1f0 100644 --- a/src/models/resource.model.ts +++ b/src/models/resource.model.ts @@ -65,6 +65,7 @@ export interface Resource extends Document { layouts: any; aggregations: any; importField?: string; + triggersFilters?: any; } /** Mongoose resource schema definition */ @@ -150,6 +151,10 @@ const resourceSchema = new Schema( type: mongoose.Schema.Types.Mixed, default: [], }, + triggersFilters: { + type: mongoose.Schema.Types.Mixed, + default: {}, + }, layouts: [layoutSchema], aggregations: [aggregationSchema], importField: { diff --git a/src/schema/mutation/editResource.mutation.ts b/src/schema/mutation/editResource.mutation.ts index 3a668cd47..ff2af8200 100644 --- a/src/schema/mutation/editResource.mutation.ts +++ b/src/schema/mutation/editResource.mutation.ts @@ -567,6 +567,7 @@ type EditResourceArgs = { permissions?: any; fieldsPermissions?: any; calculatedField?: any; + triggersFilters?: any; idShape?: DefaultIncrementalIdShapeT; importField?: string; }; @@ -583,6 +584,7 @@ export default { permissions: { type: GraphQLJSON }, fieldsPermissions: { type: GraphQLJSON }, calculatedField: { type: GraphQLJSON }, + triggersFilters: { type: GraphQLJSON }, idShape: { type: IdShapeType }, importField: { type: GraphQLString }, }, @@ -597,6 +599,7 @@ export default { !args.calculatedField && !args.fieldsPermissions && !args.idShape && + !args.triggersFilters && !args.importField) ) { throw new GraphQLError( @@ -839,6 +842,18 @@ export default { } } + if (args.triggersFilters) { + if (update.$set) { + Object.assign(update.$set, { + ['triggersFilters']: args.triggersFilters, + }); + } else { + Object.assign(update, { + $set: { ['triggersFilters']: args.triggersFilters }, + }); + } + } + // Split the request in three parts, to avoid conflict if (!!update.$set) { await Resource.findByIdAndUpdate(args.id, { $set: update.$set }); diff --git a/src/schema/types/customNotification.type.ts b/src/schema/types/customNotification.type.ts index 709438845..57b04c82d 100644 --- a/src/schema/types/customNotification.type.ts +++ b/src/schema/types/customNotification.type.ts @@ -36,7 +36,6 @@ export const CustomNotificationType = new GraphQLObjectType({ onRecordCreation: { type: GraphQLBoolean }, onRecordUpdate: { type: GraphQLBoolean }, applicationTrigger: { type: GraphQLBoolean }, - filter: { type: GraphQLJSON }, }), }); diff --git a/src/schema/types/resource.type.ts b/src/schema/types/resource.type.ts index dc4401832..91cc87caa 100644 --- a/src/schema/types/resource.type.ts +++ b/src/schema/types/resource.type.ts @@ -385,6 +385,7 @@ export const ResourceType = new GraphQLObjectType({ return getMetaData(parent, context); }, }, + triggersFilters: { type: GraphQLJSON }, }), }); From 66a15a42ba4cd9428c95b4b63d6f3d0faf7dd9ed Mon Sep 17 00:00:00 2001 From: Estela Silva Date: Mon, 5 Aug 2024 14:41:43 -0300 Subject: [PATCH 03/15] updated customNotification input and resource type --- src/schema/inputs/customNotification.input.ts | 11 +++++++++-- src/schema/types/resource.type.ts | 6 ++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/schema/inputs/customNotification.input.ts b/src/schema/inputs/customNotification.input.ts index e619c3b6b..5ae9e578e 100644 --- a/src/schema/inputs/customNotification.input.ts +++ b/src/schema/inputs/customNotification.input.ts @@ -3,6 +3,7 @@ import { GraphQLNonNull, GraphQLString, GraphQLID, + GraphQLBoolean, } from 'graphql'; import { Types } from 'mongoose'; @@ -10,13 +11,16 @@ import { Types } from 'mongoose'; export type CustomNotificationArgs = { name: string; description?: string; - schedule: string; + schedule?: string; notificationType: string; resource: string | Types.ObjectId; layout: string | Types.ObjectId; template: string | Types.ObjectId; recipients: string; recipientsType: string; + onRecordCreation?: boolean; + onRecordUpdate?: boolean; + applicationTrigger?: boolean; // eslint-disable-next-line @typescript-eslint/naming-convention notification_status?: string; }; @@ -28,13 +32,16 @@ export const CustomNotificationInputType = new GraphQLInputObjectType({ fields: () => ({ name: { type: new GraphQLNonNull(GraphQLString) }, description: { type: GraphQLString }, - schedule: { type: new GraphQLNonNull(GraphQLString) }, + schedule: { type: GraphQLString }, notificationType: { type: new GraphQLNonNull(GraphQLString) }, resource: { type: new GraphQLNonNull(GraphQLID) }, layout: { type: new GraphQLNonNull(GraphQLID) }, template: { type: new GraphQLNonNull(GraphQLID) }, recipients: { type: new GraphQLNonNull(GraphQLString) }, recipientsType: { type: new GraphQLNonNull(GraphQLString) }, + onRecordCreation: { type: GraphQLBoolean }, + onRecordUpdate: { type: GraphQLBoolean }, + applicationTrigger: { type: GraphQLBoolean }, // notification_status: { type: new GraphQLNonNull(GraphQLString) }, }), }); diff --git a/src/schema/types/resource.type.ts b/src/schema/types/resource.type.ts index 91cc87caa..4e7b79a4c 100644 --- a/src/schema/types/resource.type.ts +++ b/src/schema/types/resource.type.ts @@ -305,6 +305,12 @@ export const ResourceType = new GraphQLObjectType({ return ability.can('delete', parent); }, }, + hasLayouts: { + type: GraphQLBoolean, + resolve(parent) { + return parent.layouts?.length; + }, + }, layouts: { type: LayoutConnectionType, args: { From 009898ee85226f80352008d6ac67ba190579f3ea Mon Sep 17 00:00:00 2001 From: Estela Silva Date: Mon, 5 Aug 2024 17:13:12 -0300 Subject: [PATCH 04/15] fix: enumType typo --- src/const/enumTypes.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/const/enumTypes.ts b/src/const/enumTypes.ts index 1a2947dff..e5402345b 100644 --- a/src/const/enumTypes.ts +++ b/src/const/enumTypes.ts @@ -101,6 +101,7 @@ export const customNotificationRecipientsType = { userField: 'userField', emailField: 'userField', distributionList: 'distributionList', + channel: 'channel', }; /** From e0a5e143bada5b1082ac61fbca78beb6ab001f45 Mon Sep 17 00:00:00 2001 From: Estela Silva Date: Wed, 7 Aug 2024 17:49:31 -0300 Subject: [PATCH 05/15] added notification template --- src/const/placeholders.ts | 1 + src/models/template.model.ts | 2 +- src/schema/mutation/editTemplate.mutation.ts | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/const/placeholders.ts b/src/const/placeholders.ts index 5f6518669..891d65236 100644 --- a/src/const/placeholders.ts +++ b/src/const/placeholders.ts @@ -5,6 +5,7 @@ export enum Placeholder { DATASET = '{{dataset}}', NOW = '{{now}}', LAST_UPDATE = '{{lastUpdate}}', + RECORD_ID = '{{recordId}}', } /** Regex to detect placeholder usage. */ diff --git a/src/models/template.model.ts b/src/models/template.model.ts index 5982f1964..6d0c9eb82 100644 --- a/src/models/template.model.ts +++ b/src/models/template.model.ts @@ -17,7 +17,7 @@ export const templateSchema = new Schema( /** template documents interface declaration */ export interface Template extends Document { kind: 'Template'; - type?: 'email'; // In the case we add other types of templates in the future + type?: 'email' | 'notification'; // In the case we add other types of templates in the future name?: string; content?: any; createdAt?: Date; diff --git a/src/schema/mutation/editTemplate.mutation.ts b/src/schema/mutation/editTemplate.mutation.ts index 9d6cce721..b80c11021 100644 --- a/src/schema/mutation/editTemplate.mutation.ts +++ b/src/schema/mutation/editTemplate.mutation.ts @@ -44,6 +44,7 @@ export default { $set: { 'templates.$.name': args.template.name, 'templates.$.content': args.template.content, + 'templates.$.type': args.template.type, }, }; From db8ca741082a24dd5bfad836e45c87b86582783b Mon Sep 17 00:00:00 2001 From: Estela Silva Date: Thu, 8 Aug 2024 14:38:28 -0300 Subject: [PATCH 06/15] re-factored resource triggersFilters: added application id to each object --- src/models/resource.model.ts | 4 ++-- src/schema/mutation/editResource.mutation.ts | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/models/resource.model.ts b/src/models/resource.model.ts index fa78eb1f0..37e50580e 100644 --- a/src/models/resource.model.ts +++ b/src/models/resource.model.ts @@ -152,8 +152,8 @@ const resourceSchema = new Schema( default: [], }, triggersFilters: { - type: mongoose.Schema.Types.Mixed, - default: {}, + type: [mongoose.Schema.Types.Mixed], + default: [], }, layouts: [layoutSchema], aggregations: [aggregationSchema], diff --git a/src/schema/mutation/editResource.mutation.ts b/src/schema/mutation/editResource.mutation.ts index ff2af8200..62b56bac7 100644 --- a/src/schema/mutation/editResource.mutation.ts +++ b/src/schema/mutation/editResource.mutation.ts @@ -843,13 +843,25 @@ export default { } if (args.triggersFilters) { + let triggersFilters = [args.triggersFilters]; + if (resource.triggersFilters.length) { + triggersFilters = [...resource.triggersFilters]; + const index = resource.triggersFilters.findIndex( + (tg: any) => tg.application === args.triggersFilters.application + ); + if (index !== -1) { + triggersFilters[index] = args.triggersFilters; + } else { + triggersFilters.push(args.triggersFilters); + } + } if (update.$set) { Object.assign(update.$set, { - ['triggersFilters']: args.triggersFilters, + ['triggersFilters']: triggersFilters, }); } else { Object.assign(update, { - $set: { ['triggersFilters']: args.triggersFilters }, + $set: { ['triggersFilters']: triggersFilters }, }); } } From 2612b878407d30d87fb85352895b842d392d6c59 Mon Sep 17 00:00:00 2001 From: Estela Silva Date: Thu, 8 Aug 2024 17:51:03 -0300 Subject: [PATCH 07/15] fix: resource type resolver --- src/schema/types/resource.type.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/schema/types/resource.type.ts b/src/schema/types/resource.type.ts index 4e7b79a4c..613042963 100644 --- a/src/schema/types/resource.type.ts +++ b/src/schema/types/resource.type.ts @@ -265,9 +265,13 @@ export const ResourceType = new GraphQLObjectType({ ).populate({ path: 'customNotifications', model: 'CustomNotification', - match: { applicationTrigger: true }, }); - return application?.customNotifications ?? []; + const filteredNotifications = application.customNotifications.filter( + (notification) => + notification.applicationTrigger === true && + notification.resource.equals(parent._id) + ); + return filteredNotifications ?? []; } return []; }, From c66730e2d37c2b612c97515d7be5fd3d5973e97d Mon Sep 17 00:00:00 2001 From: Estela Silva Date: Fri, 9 Aug 2024 15:51:12 -0300 Subject: [PATCH 08/15] updated custom notification mutations --- src/schema/inputs/customNotification.input.ts | 2 +- src/schema/mutation/addCustomNotification.mutation.ts | 6 +++++- src/schema/mutation/editCustomNotification.mutation.ts | 9 ++++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/schema/inputs/customNotification.input.ts b/src/schema/inputs/customNotification.input.ts index 5ae9e578e..36ec53470 100644 --- a/src/schema/inputs/customNotification.input.ts +++ b/src/schema/inputs/customNotification.input.ts @@ -42,6 +42,6 @@ export const CustomNotificationInputType = new GraphQLInputObjectType({ onRecordCreation: { type: GraphQLBoolean }, onRecordUpdate: { type: GraphQLBoolean }, applicationTrigger: { type: GraphQLBoolean }, - // notification_status: { type: new GraphQLNonNull(GraphQLString) }, + notification_status: { type: new GraphQLNonNull(GraphQLString) }, }), }); diff --git a/src/schema/mutation/addCustomNotification.mutation.ts b/src/schema/mutation/addCustomNotification.mutation.ts index d88a26d0d..5105525c3 100644 --- a/src/schema/mutation/addCustomNotification.mutation.ts +++ b/src/schema/mutation/addCustomNotification.mutation.ts @@ -67,6 +67,9 @@ export default { recipients: args.notification.recipients, status: args.notification.notification_status, recipientsType: args.notification.recipientsType, + onRecordCreation: args.notification.onRecordCreation, + onRecordUpdate: args.notification.onRecordUpdate, + applicationTrigger: args.notification.applicationTrigger, }, }, }; @@ -78,8 +81,9 @@ export default { ); const notificationDetail = application.customNotifications.pop(); if ( + args.notification.schedule && args.notification.notification_status === - customNotificationStatus.active + customNotificationStatus.active ) { scheduleCustomNotificationJob(notificationDetail, application); } diff --git a/src/schema/mutation/editCustomNotification.mutation.ts b/src/schema/mutation/editCustomNotification.mutation.ts index 7ecabbd79..ee788a412 100644 --- a/src/schema/mutation/editCustomNotification.mutation.ts +++ b/src/schema/mutation/editCustomNotification.mutation.ts @@ -74,6 +74,12 @@ export default { 'customNotifications.$.status': args.notification.notification_status, 'customNotifications.$.recipientsType': args.notification.recipientsType, + 'customNotifications.$.onRecordCreation': + args.notification.onRecordCreation, + 'customNotifications.$.onRecordUpdate': + args.notification.onRecordUpdate, + 'customNotifications.$.applicationTrigger': + args.notification.applicationTrigger, }, }; @@ -87,8 +93,9 @@ export default { (customNotification) => customNotification.id.toString() === args.id ); if ( + args.notification.schedule && args.notification.notification_status === - customNotificationStatus.active + customNotificationStatus.active ) { scheduleCustomNotificationJob(notificationDetail, application); } else { From cc607e79d3708d64bedd95a5751f37aba53fe6a2 Mon Sep 17 00:00:00 2001 From: Estela Silva Date: Mon, 12 Aug 2024 15:09:42 -0300 Subject: [PATCH 09/15] on edit resource if changes on triggersFilters re-schedule triggers --- src/schema/mutation/editResource.mutation.ts | 31 ++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/schema/mutation/editResource.mutation.ts b/src/schema/mutation/editResource.mutation.ts index 62b56bac7..4e0986470 100644 --- a/src/schema/mutation/editResource.mutation.ts +++ b/src/schema/mutation/editResource.mutation.ts @@ -13,7 +13,7 @@ import { getExpressionFromString, OperationTypeMap, } from '@utils/aggregation/expressionFromString'; -import { DefaultIncrementalIdShapeT, Resource } from '@models'; +import { Application, DefaultIncrementalIdShapeT, Resource } from '@models'; import { AppAbility } from '@security/defineUserAbility'; import { get, has, isArray, isEqual, isNil } from 'lodash'; import { logger } from '@services/logger.service'; @@ -21,6 +21,8 @@ import { graphQLAuthCheck } from '@schema/shared'; import { Context } from '@server/apollo/context'; import { IdShapeType } from '@schema/inputs/id-shape.input'; import buildCalculatedFieldPipeline from '@utils/aggregation/buildCalculatedFieldPipeline'; +import { customNotificationStatus } from '@const/enumTypes'; +import { scheduleCustomNotificationJob } from '@server/customNotificationScheduler'; /** Simple resource permission change type */ type SimplePermissionChange = @@ -879,11 +881,36 @@ export default { await updateIncrementalIds(resource, args.idShape); } - return await Resource.findByIdAndUpdate( + // Make sure that filters changes are applied in the scheduled notifications + if (args.triggersFilters) { + const application = await Application.findById( + args.triggersFilters.application + ).populate({ + path: 'customNotifications', + model: 'CustomNotification', + }); + const filteredNotifications = application.customNotifications.filter( + (notification) => + notification.applicationTrigger === true && + notification.resource.equals(args.id) + ); + filteredNotifications.forEach((notification) => { + if ( + notification.schedule && + notification.applicationTrigger && + notification.status === customNotificationStatus.active + ) { + scheduleCustomNotificationJob(notification, application); + } + }); + } + + const updatedResource = await Resource.findByIdAndUpdate( args.id, update.$addToSet ? { $addToSet: update.$addToSet } : {}, { new: true } ); + return updatedResource; } catch (err) { logger.error(err.message, { stack: err.stack }); if (err instanceof GraphQLError) throw err; From 93e7d8a9cd4a7c223971cb6f3831f2899a961b57 Mon Sep 17 00:00:00 2001 From: Estela Silva Date: Tue, 13 Aug 2024 13:21:52 -0300 Subject: [PATCH 10/15] wip: scheduler custom notification and notification model updates --- src/models/notification.model.ts | 15 +- src/schema/inputs/customNotification.input.ts | 5 +- .../addCustomNotification.mutation.ts | 5 +- .../editCustomNotification.mutation.ts | 5 +- src/schema/types/notification.type.ts | 1 + src/server/customNotificationScheduler.ts | 504 +++++++++++++----- 6 files changed, 379 insertions(+), 156 deletions(-) diff --git a/src/models/notification.model.ts b/src/models/notification.model.ts index 90a41697b..24fcac423 100644 --- a/src/models/notification.model.ts +++ b/src/models/notification.model.ts @@ -9,12 +9,15 @@ const notificationSchema = new Schema( channel: { type: mongoose.Schema.Types.ObjectId, ref: 'Channel', - required: true, }, seenBy: { type: [mongoose.Schema.Types.ObjectId], ref: 'User', }, + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + }, }, { timestamps: { createdAt: 'createdAt', updatedAt: 'modifiedAt' }, @@ -34,8 +37,18 @@ export interface Notification extends Document { createdAt: Date; channel: any; seenBy: any[]; + user: any[]; } +notificationSchema.pre('validate', function (next) { + if (!this.channel && !this.user) { + return next( + new Error('At least only field (channels, user) should be populated') + ); + } + next(); +}); + notificationSchema.plugin(accessibleRecordsPlugin); /** Mongoose notification model definition */ diff --git a/src/schema/inputs/customNotification.input.ts b/src/schema/inputs/customNotification.input.ts index 36ec53470..5ae9e6877 100644 --- a/src/schema/inputs/customNotification.input.ts +++ b/src/schema/inputs/customNotification.input.ts @@ -21,8 +21,7 @@ export type CustomNotificationArgs = { onRecordCreation?: boolean; onRecordUpdate?: boolean; applicationTrigger?: boolean; - // eslint-disable-next-line @typescript-eslint/naming-convention - notification_status?: string; + status?: string; }; /** GraphQL custom notification query input type definition */ @@ -42,6 +41,6 @@ export const CustomNotificationInputType = new GraphQLInputObjectType({ onRecordCreation: { type: GraphQLBoolean }, onRecordUpdate: { type: GraphQLBoolean }, applicationTrigger: { type: GraphQLBoolean }, - notification_status: { type: new GraphQLNonNull(GraphQLString) }, + status: { type: new GraphQLNonNull(GraphQLString) }, }), }); diff --git a/src/schema/mutation/addCustomNotification.mutation.ts b/src/schema/mutation/addCustomNotification.mutation.ts index 5105525c3..4061b4973 100644 --- a/src/schema/mutation/addCustomNotification.mutation.ts +++ b/src/schema/mutation/addCustomNotification.mutation.ts @@ -65,7 +65,7 @@ export default { layout: args.notification.layout, template: args.notification.template, recipients: args.notification.recipients, - status: args.notification.notification_status, + status: args.notification.status, recipientsType: args.notification.recipientsType, onRecordCreation: args.notification.onRecordCreation, onRecordUpdate: args.notification.onRecordUpdate, @@ -82,8 +82,7 @@ export default { const notificationDetail = application.customNotifications.pop(); if ( args.notification.schedule && - args.notification.notification_status === - customNotificationStatus.active + args.notification.status === customNotificationStatus.active ) { scheduleCustomNotificationJob(notificationDetail, application); } diff --git a/src/schema/mutation/editCustomNotification.mutation.ts b/src/schema/mutation/editCustomNotification.mutation.ts index ee788a412..60309b9ac 100644 --- a/src/schema/mutation/editCustomNotification.mutation.ts +++ b/src/schema/mutation/editCustomNotification.mutation.ts @@ -71,7 +71,7 @@ export default { 'customNotifications.$.layout': args.notification.layout, 'customNotifications.$.template': args.notification.template, 'customNotifications.$.recipients': args.notification.recipients, - 'customNotifications.$.status': args.notification.notification_status, + 'customNotifications.$.status': args.notification.status, 'customNotifications.$.recipientsType': args.notification.recipientsType, 'customNotifications.$.onRecordCreation': @@ -94,8 +94,7 @@ export default { ); if ( args.notification.schedule && - args.notification.notification_status === - customNotificationStatus.active + args.notification.status === customNotificationStatus.active ) { scheduleCustomNotificationJob(notificationDetail, application); } else { diff --git a/src/schema/types/notification.type.ts b/src/schema/types/notification.type.ts index 7c4257a78..100d635d4 100644 --- a/src/schema/types/notification.type.ts +++ b/src/schema/types/notification.type.ts @@ -42,6 +42,7 @@ export const NotificationType = new GraphQLObjectType({ return users; }, }, + user: { type: new GraphQLList(UserType) }, }), }); diff --git a/src/server/customNotificationScheduler.ts b/src/server/customNotificationScheduler.ts index 297e57d2b..7aae9f3aa 100644 --- a/src/server/customNotificationScheduler.ts +++ b/src/server/customNotificationScheduler.ts @@ -3,14 +3,21 @@ import { CustomNotification, Resource, Record as RecordModel, + Notification, User, + Channel, } from '@models'; import { CronJob } from 'cron'; import { logger } from '../services/logger.service'; import * as cronValidator from 'cron-validator'; import get from 'lodash/get'; import { sendEmail, preprocess } from '@utils/email'; -import { customNotificationRecipientsType } from '@const/enumTypes'; +import { + customNotificationRecipientsType, + customNotificationType, +} from '@const/enumTypes'; +import pubsub from './pubsub'; +import getFilter from '@utils/schema/resolvers/Query/getFilter'; /** A map with the custom notification ids as keys and the scheduled custom notification as values */ const customNotificationMap: Record = {}; @@ -19,17 +26,17 @@ const customNotificationMap: Record = {}; * Global function called on server start to initialize all the custom notification. */ const customNotificationScheduler = async () => { - // const applications = await Application.find({ - // customNotifications: { $elemMatch: { status: 'active' } }, - // }); - // for (const application of applications) { - // if (!!application.customNotifications) { - // for await (const notification of application.customNotifications) { - // // eslint-disable-next-line @typescript-eslint/no-use-before-define - // scheduleCustomNotificationJob(notification, application); - // } - // } - // } + const applications = await Application.find({ + customNotifications: { $elemMatch: { status: 'active' } }, + }); + for (const application of applications) { + if (!!application.customNotifications) { + for await (const notification of application.customNotifications) { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + scheduleCustomNotificationJob(notification, application); + } + } + } }; export default customNotificationScheduler; @@ -47,11 +54,12 @@ const customNotificationMailSend = async ( notification ) => { if (!!template && recipients.length > 0) { + // console.log('content: ', template, template.content); await sendEmail({ message: { to: recipients, - subject: template.name, - html: template.content, + subject: template.content.subject, + html: template.content.body, attachments: [], }, }); @@ -62,6 +70,127 @@ const customNotificationMailSend = async ( } }; +/** + * Depending on notification type, for custom notification + * + * @param content template content + * @param notificationType notification type + * @param fields fields to process + * @param rows data of records rows + */ +const processTemplateContent = async ( + content, + notificationType, + fields, + rows +) => { + if (notificationType === customNotificationType.email) { + content.body = await preprocess(content.body, { + fields, + rows, + }); + content.subject = await preprocess(content.subject, { + fields, + rows, + }); + } else { + content.title = await preprocess(content.title, { + fields, + rows, + }); + content.description = await preprocess(content.description, { + fields, + rows, + }); + } + return content; +}; + +/** + * Send email for custom notification + * + * @param template processed email template + * @param recipients custom notification recipients (form id or users from user field) + * @param notification custom notification + */ +const customNotificationNotificationSend = async ( + template, + recipients, + notification +) => { + if (!!template && !!recipients) { + if ( + notification.recipientsType === customNotificationRecipientsType.channel + ) { + // Send notification to channel + const channel = await Channel.findById(recipients[0]); + if (channel) { + // const content = JSON.parse(template.content); + // console.log('TEM channel', template.content.title, channel.id); + const notificationInstance = new Notification({ + action: template.content.title, + content: template.content.description, + //createdAt: new Date(), + channel: channel.id, + seenBy: [], + }); + await notificationInstance.save(); + const publisher = await pubsub(); + publisher.publish(channel.id, { notificationInstance }); + } + } else if ( + notification.recipientsType === customNotificationRecipientsType.userField + ) { + // Send notification to a user + const notificationInstance = new Notification({ + action: template.content.title, + content: template.content.description, + //createdAt: new Date(), + user: recipients, + seenBy: [], + }); + await notificationInstance.save(); + const publisher = await pubsub(); + publisher.publish(recipients, { notificationInstance }); + } + } else { + throw new Error( + `[${notification.name}] notification template not available or recipients not available:` + ); + } +}; + +/** + * Prepare custom notification to be sent by type (email or notification) + * + * @param template processed email template + * @param recipients custom notification recipients (always a array) + * (can be a single email, a list of emails, a channel id or users from a user field) + * @param notification custom notification + */ +const customNotificationSend = async (template, recipients, notification) => { + if (!!template && recipients.length > 0) { + const notificationType = get(notification, 'notificationType', 'email'); + if (notificationType === customNotificationType.email) { + // console.log('customNotificationMailSend'); + // If custom notification type is email + await customNotificationMailSend(template, recipients, notification); + } else { + // console.log('customNotificationNotificationSend'); + // If custom notification type is notification + await customNotificationNotificationSend( + template, + recipients, + notification + ); + } + } else { + throw new Error( + `[${notification.name}] notification template not available or recipients not available:` + ); + } +}; + /** * Schedule or re-schedule a custom notification. * @@ -73,167 +202,250 @@ export const scheduleCustomNotificationJob = async ( application: Application ) => { try { + // console.log('scheduleCustomNotificationJob'); const task = get(customNotificationMap, notification.id, null); if (task) { task.stop(); } const schedule = get(notification, 'schedule', ''); - if (cronValidator.isValidCron(schedule)) { - customNotificationMap[notification.id] = new CronJob( - notification.schedule, - async () => { - try { - const template = application.templates.find( - (x) => x._id.toString() === notification.template.toString() - ); - - let recipients: string[] = []; - let userField = ''; - switch (notification.recipientsType) { - // Use single email as recipients - case customNotificationRecipientsType.email: { - recipients = [notification.recipients]; - break; - } - // Use distribution list as recipients - case customNotificationRecipientsType.distributionList: { - const distribution = application.distributionLists.find( - (x) => x._id.toString() === notification.recipients - ); - recipients = get(distribution, 'emails', []); - break; - } - // Use dataset field as recipients - case customNotificationRecipientsType.userField: { - userField = notification.recipients; - break; - } - } - - const resource = await Resource.findOne({ - _id: notification.resource, - }); - if (resource) { - const layout = resource.layouts.find( - (x) => x._id.toString() === notification.layout.toString() + if (schedule) { + if (cronValidator.isValidCron(schedule)) { + customNotificationMap[notification.id] = new CronJob( + notification.schedule, + async () => { + try { + const template = application.templates.find( + (x) => x._id.toString() === notification.template.toString() ); - const fieldArr = []; - for (const field of resource.fields) { - const layoutField = layout.query.fields.find( - (fieldDetail) => fieldDetail.name == field.name - ); + const notificationType = get( + notification, + 'notificationType', + 'email' + ); - if (field.type != 'users') { - const obj = { - name: field.name, - field: field.name, - type: field.type, - meta: { - field: field, - }, - title: layoutField.label, - }; - fieldArr.push(obj); + let recipients: string[] = []; + let userField = ''; + let emailField = ''; + switch (notification.recipientsType) { + // Use single email as recipients + case customNotificationRecipientsType.email: { + recipients = [notification.recipients]; + break; + } + // Use distribution list as recipients + case customNotificationRecipientsType.distributionList: { + const distribution = application.distributionLists.find( + (x) => x._id.toString() === notification.recipients + ); + recipients = get(distribution, 'emails', []); + break; + } + // Use dataset user question field as recipients + case customNotificationRecipientsType.userField: { + userField = notification.recipients; + break; + } + // Use dataset email question field as recipients + case customNotificationRecipientsType.emailField: { + emailField = notification.recipients; + break; + } + // Use channel as recipients + case customNotificationRecipientsType.channel: { + recipients = [notification.recipients]; + break; } } - const records = await RecordModel.find({ - resource: notification.resource, + + const resource = await Resource.findOne({ + _id: notification.resource, }); + if (resource) { + const layout = resource.layouts.find( + (x) => x._id.toString() === notification.layout.toString() + ); - const recordListArr = []; - for (const record of records) { - if (record.data) { - Object.keys(record.data).forEach(function (key) { - record.data[key] = - typeof record.data[key] == 'object' - ? record.data[key].join(',') - : record.data[key]; - }); - recordListArr.push(record.data); - } - } + const fieldArr = []; + for (const field of resource.fields) { + const layoutField = layout.query.fields.find( + (fieldDetail) => fieldDetail.name == field.name + ); - if (!!userField) { - const groupRecordArr = []; - const groupValArr = []; - for (const record of recordListArr) { - const index = groupValArr.indexOf(record[userField]); - if (index == -1) { - groupValArr.push(record[userField]); - delete record[userField]; - groupRecordArr.push([record]); - } else { - delete record[userField]; - groupRecordArr[index].push(record); + if (field.type != 'users') { + const obj = { + name: field.name, + field: field.name, + type: field.type, + meta: { + field: field, + }, + title: layoutField?.label || layoutField?.name, + }; + fieldArr.push(obj); } } + // If triggers, check if has filters + let mongooseFilter = {}; + if (notification.applicationTrigger) { + const triggersFilters = resource.triggersFilters.find( + (tg: any) => tg.application === application.id + ); + if ( + triggersFilters && + Object.prototype.hasOwnProperty.call( + triggersFilters, + 'cronBased' + ) + ) { + // TODO: fix always returning empty {} + // Filter from the query definition + mongooseFilter = getFilter( + triggersFilters.cronBased, + resource.fields + ); + } + } + // console.log('--- mongooseFilter: ', mongooseFilter); + const records = await RecordModel.aggregate([ + { + $match: { + $and: [ + { + resource: notification.resource, + }, + mongooseFilter, + ], + }, + }, + ]); - let d = 0; - const templateContent = template.content; - for await (const groupRecord of groupRecordArr) { - if (groupRecord.length > 0) { - template.content = await preprocess(templateContent, { - fields: fieldArr, - rows: groupRecord, + const recordListArr = []; + for (const record of records) { + if (record.data) { + Object.keys(record.data).forEach(function (key) { + record.data[key] = + typeof record.data[key] == 'object' + ? record.data[key]?.join(',') + : record.data[key]; }); + recordListArr.push({ ...record.data, id: record._id }); } + } - const userDetail = await User.findById(groupValArr[d]); - if (!!userDetail && !!userDetail.username) { - recipients = [userDetail.username]; - await customNotificationMailSend( - template, - recipients, - notification - ); + if (!!userField || !!emailField) { + const field = userField || emailField; + const groupRecordArr = []; + const groupValArr = []; + for (const record of recordListArr) { + const index = groupValArr.indexOf(record[field]); + if (index == -1) { + groupValArr.push(record[field]); + delete record[field]; + groupRecordArr.push([record]); + } else { + delete record[field]; + groupRecordArr[index].push(record); + } } - d++; + + let d = 0; + for await (const groupRecord of groupRecordArr) { + if (groupRecord.length > 0) { + template.content = await processTemplateContent( + template.content, + notificationType, + fieldArr, + groupRecord + ); + // console.log('template.content: ', template.content); + } + if (!!userField) { + // If using userField, get the user with the id saved in the record data + const userDetail = await User.findById(groupValArr[d]); + if (!!userDetail && !!userDetail.username) { + if (notificationType === customNotificationType.email) { + // If email type, should get user email + recipients = [userDetail.username]; + await customNotificationSend( + template, + recipients, + notification + ); + } else { + // If notification type, should get user id + recipients = userDetail.id; + await customNotificationSend( + template, + recipients, + notification + ); + } + } + } else { + // If using emailField, get the email saved in the record data + recipients = groupValArr[d]; + await customNotificationSend( + template, + recipients, + notification + ); + } + d++; + } + } else { + template.content = await processTemplateContent( + template.content, + notificationType, + fieldArr, + recordListArr + ); + // console.log('template.content: ', template.content); + await customNotificationSend( + template, + recipients, + notification + ); } } else { - template.content = preprocess(template.content, { - fields: fieldArr, - rows: recordListArr, - }); - await customNotificationMailSend( + await customNotificationSend( template, recipients, notification ); } - } else { - await customNotificationMailSend( - template, - recipients, - notification + + const update = { + $set: { + 'customNotifications.$.lastExecutionStatus': 'success', + 'customNotifications.$.lastExecution': new Date(), + }, + }; + await Application.findOneAndUpdate( + { + _id: application._id, + 'customNotifications._id': notification._id, + }, + update ); + } catch (error) { + logger.error(error.message, { stack: error.stack }); } - - const update = { - $set: { - 'customNotifications.$.lastExecutionStatus': 'success', - 'customNotifications.$.lastExecution': new Date(), - }, - }; - await Application.findOneAndUpdate( - { - _id: application._id, - 'customNotifications._id': notification._id, - }, - update - ); - } catch (error) { - logger.error(error.message, { stack: error.stack }); - } - }, - null, - true - ); - logger.info('📅 Scheduled custom notification job ' + notification.name); - } else { - throw new Error( - `[${notification.name}] Invalid custom notification schedule: ${schedule}` - ); + }, + null, + true + ); + logger.info( + '📅 Scheduled custom notification job ' + notification.name + ); + } else { + throw new Error( + `[${notification.name}] Invalid custom notification schedule: ${schedule}` + ); + } + } else if (task) { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + unscheduleCustomNotificationJob(notification); } } catch (err) { logger.error(err.message); From 821837aafc13c75ace009ac67c7c2276c753c9d9 Mon Sep 17 00:00:00 2001 From: Estela Silva Date: Wed, 14 Aug 2024 18:52:58 -0300 Subject: [PATCH 11/15] added redirect to notification in trigger + notifications.query take into account users and not only channel --- src/models/customNotification.model.ts | 8 ++++++++ src/models/notification.model.ts | 10 +++++++++- src/schema/inputs/customNotification.input.ts | 8 ++++++++ src/schema/mutation/addCustomNotification.mutation.ts | 1 + src/schema/mutation/editCustomNotification.mutation.ts | 1 + src/schema/query/notifications.query.ts | 6 ++++-- src/schema/types/customNotification.type.ts | 1 + src/schema/types/notification.type.ts | 3 ++- 8 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/models/customNotification.model.ts b/src/models/customNotification.model.ts index 080dc884d..00cfd9162 100644 --- a/src/models/customNotification.model.ts +++ b/src/models/customNotification.model.ts @@ -64,6 +64,7 @@ export const customNotificationSchema = new Schema( onRecordCreation: Boolean, onRecordUpdate: Boolean, applicationTrigger: Boolean, + redirect: mongoose.Schema.Types.Mixed, }, { timestamps: { createdAt: 'createdAt', updatedAt: 'modifiedAt' }, @@ -90,4 +91,11 @@ export interface CustomNotification extends Document { onRecordCreation?: boolean; onRecordUpdate?: boolean; // record deletion counts as an update applicationTrigger?: boolean; + redirect?: any; + // redirect?: { + // active: boolean; + // type: string; // 'url' | 'recordIds' + // url?: string; + // recordIds?: string[]; + // }; } diff --git a/src/models/notification.model.ts b/src/models/notification.model.ts index 24fcac423..8ba5b8931 100644 --- a/src/models/notification.model.ts +++ b/src/models/notification.model.ts @@ -18,6 +18,7 @@ const notificationSchema = new Schema( type: mongoose.Schema.Types.ObjectId, ref: 'User', }, + redirect: mongoose.Schema.Types.Mixed, }, { timestamps: { createdAt: 'createdAt', updatedAt: 'modifiedAt' }, @@ -37,7 +38,14 @@ export interface Notification extends Document { createdAt: Date; channel: any; seenBy: any[]; - user: any[]; + user?: any; + redirect?: any; + // redirect?: { + // active: boolean; + // type: string; // 'url' | 'recordIds' + // url?: string; + // recordIds?: string[]; + // }; } notificationSchema.pre('validate', function (next) { diff --git a/src/schema/inputs/customNotification.input.ts b/src/schema/inputs/customNotification.input.ts index 5ae9e6877..3456fec35 100644 --- a/src/schema/inputs/customNotification.input.ts +++ b/src/schema/inputs/customNotification.input.ts @@ -5,6 +5,7 @@ import { GraphQLID, GraphQLBoolean, } from 'graphql'; +import GraphQLJSON from 'graphql-type-json'; import { Types } from 'mongoose'; /** Custom Notification type for queries/mutations argument */ @@ -22,6 +23,12 @@ export type CustomNotificationArgs = { onRecordUpdate?: boolean; applicationTrigger?: boolean; status?: string; + redirect?: { + active: boolean; + type: string; // 'url' | 'recordIds' + url?: string; + recordIds?: string[]; + }; }; /** GraphQL custom notification query input type definition */ @@ -42,5 +49,6 @@ export const CustomNotificationInputType = new GraphQLInputObjectType({ onRecordUpdate: { type: GraphQLBoolean }, applicationTrigger: { type: GraphQLBoolean }, status: { type: new GraphQLNonNull(GraphQLString) }, + redirect: { type: GraphQLJSON }, }), }); diff --git a/src/schema/mutation/addCustomNotification.mutation.ts b/src/schema/mutation/addCustomNotification.mutation.ts index 4061b4973..98614b930 100644 --- a/src/schema/mutation/addCustomNotification.mutation.ts +++ b/src/schema/mutation/addCustomNotification.mutation.ts @@ -70,6 +70,7 @@ export default { onRecordCreation: args.notification.onRecordCreation, onRecordUpdate: args.notification.onRecordUpdate, applicationTrigger: args.notification.applicationTrigger, + redirect: args.notification.redirect, }, }, }; diff --git a/src/schema/mutation/editCustomNotification.mutation.ts b/src/schema/mutation/editCustomNotification.mutation.ts index 60309b9ac..76f5b5af1 100644 --- a/src/schema/mutation/editCustomNotification.mutation.ts +++ b/src/schema/mutation/editCustomNotification.mutation.ts @@ -80,6 +80,7 @@ export default { args.notification.onRecordUpdate, 'customNotifications.$.applicationTrigger': args.notification.applicationTrigger, + 'customNotifications.$.redirect': args.notification.redirect, }, }; diff --git a/src/schema/query/notifications.query.ts b/src/schema/query/notifications.query.ts index 892f325af..4e9c25fb6 100644 --- a/src/schema/query/notifications.query.ts +++ b/src/schema/query/notifications.query.ts @@ -54,7 +54,7 @@ export default { : {}; let items: any[] = await Notification.find({ - $and: [cursorFilters, ...filters], + $or: [{ $and: [cursorFilters, ...filters] }, { user: context.user }], }) .sort({ createdAt: -1 }) .limit(first + 1); @@ -74,7 +74,9 @@ export default { endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null, }, edges, - totalCount: await Notification.countDocuments({ $and: filters }), + totalCount: await Notification.countDocuments({ + $or: [{ $and: filters }, { user: context.user }], + }), }; } catch (err) { logger.error(err.message, { stack: err.stack }); diff --git a/src/schema/types/customNotification.type.ts b/src/schema/types/customNotification.type.ts index 57b04c82d..2c832cf58 100644 --- a/src/schema/types/customNotification.type.ts +++ b/src/schema/types/customNotification.type.ts @@ -36,6 +36,7 @@ export const CustomNotificationType = new GraphQLObjectType({ onRecordCreation: { type: GraphQLBoolean }, onRecordUpdate: { type: GraphQLBoolean }, applicationTrigger: { type: GraphQLBoolean }, + redirect: { type: GraphQLJSON }, }), }); diff --git a/src/schema/types/notification.type.ts b/src/schema/types/notification.type.ts index 100d635d4..44ac1effe 100644 --- a/src/schema/types/notification.type.ts +++ b/src/schema/types/notification.type.ts @@ -42,7 +42,8 @@ export const NotificationType = new GraphQLObjectType({ return users; }, }, - user: { type: new GraphQLList(UserType) }, + user: { type: UserType }, + redirect: { type: GraphQLJSON }, }), }); From 035f2f227d01f417e45ce383f339cb7499ecb680 Mon Sep 17 00:00:00 2001 From: Estela Silva Date: Fri, 16 Aug 2024 13:27:45 -0300 Subject: [PATCH 12/15] wip: trigger notification --- src/models/notification.model.ts | 2 + src/schema/types/record.type.ts | 8 + src/server/customNotificationScheduler.ts | 240 ++++++++++++---------- src/utils/email/gridEmailBuilder.ts | 7 + 4 files changed, 153 insertions(+), 104 deletions(-) diff --git a/src/models/notification.model.ts b/src/models/notification.model.ts index 8ba5b8931..7c2838a6f 100644 --- a/src/models/notification.model.ts +++ b/src/models/notification.model.ts @@ -45,6 +45,8 @@ export interface Notification extends Document { // type: string; // 'url' | 'recordIds' // url?: string; // recordIds?: string[]; + // layout?: string; + // resource?: string; // }; } diff --git a/src/schema/types/record.type.ts b/src/schema/types/record.type.ts index 9aaa7fe8a..5b131ed94 100644 --- a/src/schema/types/record.type.ts +++ b/src/schema/types/record.type.ts @@ -126,6 +126,14 @@ export const RecordType = new GraphQLObjectType({ } }, }, + userCanEdit: { + type: GraphQLBoolean, + async resolve(parent, args, context) { + const parentForm: Form = await Form.findById(parent.form); + const ability = await extendAbilityForRecords(context.user, parentForm); + return ability.can('update', parent); + }, + }, validationErrors: { type: new GraphQLList( new GraphQLObjectType({ diff --git a/src/server/customNotificationScheduler.ts b/src/server/customNotificationScheduler.ts index 7aae9f3aa..bdffdd432 100644 --- a/src/server/customNotificationScheduler.ts +++ b/src/server/customNotificationScheduler.ts @@ -54,7 +54,6 @@ const customNotificationMailSend = async ( notification ) => { if (!!template && recipients.length > 0) { - // console.log('content: ', template, template.content); await sendEmail({ message: { to: recipients, @@ -112,27 +111,37 @@ const processTemplateContent = async ( * @param template processed email template * @param recipients custom notification recipients (form id or users from user field) * @param notification custom notification + * @param recordsIds records ids list (if any) */ const customNotificationNotificationSend = async ( template, recipients, - notification + notification, + recordsIds ) => { if (!!template && !!recipients) { + const redirect = + notification.redirect && notification.redirect.active + ? { + ...notification.redirect, + recordIds: recordsIds, + layout: notification.layout, + resource: notification.resource, + } + : null; if ( notification.recipientsType === customNotificationRecipientsType.channel ) { // Send notification to channel const channel = await Channel.findById(recipients[0]); if (channel) { - // const content = JSON.parse(template.content); - // console.log('TEM channel', template.content.title, channel.id); const notificationInstance = new Notification({ action: template.content.title, content: template.content.description, //createdAt: new Date(), channel: channel.id, seenBy: [], + redirect, }); await notificationInstance.save(); const publisher = await pubsub(); @@ -148,6 +157,7 @@ const customNotificationNotificationSend = async ( //createdAt: new Date(), user: recipients, seenBy: [], + redirect, }); await notificationInstance.save(); const publisher = await pubsub(); @@ -167,21 +177,26 @@ const customNotificationNotificationSend = async ( * @param recipients custom notification recipients (always a array) * (can be a single email, a list of emails, a channel id or users from a user field) * @param notification custom notification + * @param recordsIds records ids list */ -const customNotificationSend = async (template, recipients, notification) => { +const customNotificationSend = async ( + template, + recipients, + notification, + recordsIds?: string[] +) => { if (!!template && recipients.length > 0) { const notificationType = get(notification, 'notificationType', 'email'); if (notificationType === customNotificationType.email) { - // console.log('customNotificationMailSend'); // If custom notification type is email await customNotificationMailSend(template, recipients, notification); } else { - // console.log('customNotificationNotificationSend'); // If custom notification type is notification await customNotificationNotificationSend( template, recipients, - notification + notification, + recordsIds ); } } else { @@ -202,7 +217,6 @@ export const scheduleCustomNotificationJob = async ( application: Application ) => { try { - // console.log('scheduleCustomNotificationJob'); const task = get(customNotificationMap, notification.id, null); if (task) { task.stop(); @@ -223,7 +237,7 @@ export const scheduleCustomNotificationJob = async ( 'notificationType', 'email' ); - + let sent = false; let recipients: string[] = []; let userField = ''; let emailField = ''; @@ -298,15 +312,14 @@ export const scheduleCustomNotificationJob = async ( 'cronBased' ) ) { - // TODO: fix always returning empty {} + // TODO: take into account resources questions // Filter from the query definition mongooseFilter = getFilter( - triggersFilters.cronBased, + { ...triggersFilters.cronBased, logic: 'and' }, resource.fields ); } } - // console.log('--- mongooseFilter: ', mongooseFilter); const records = await RecordModel.aggregate([ { $match: { @@ -320,92 +333,108 @@ export const scheduleCustomNotificationJob = async ( }, ]); - const recordListArr = []; - for (const record of records) { - if (record.data) { - Object.keys(record.data).forEach(function (key) { - record.data[key] = - typeof record.data[key] == 'object' - ? record.data[key]?.join(',') - : record.data[key]; - }); - recordListArr.push({ ...record.data, id: record._id }); - } - } - - if (!!userField || !!emailField) { - const field = userField || emailField; - const groupRecordArr = []; - const groupValArr = []; - for (const record of recordListArr) { - const index = groupValArr.indexOf(record[field]); - if (index == -1) { - groupValArr.push(record[field]); - delete record[field]; - groupRecordArr.push([record]); - } else { - delete record[field]; - groupRecordArr[index].push(record); + if (records) { + const redirectToRecords = + notification.redirect && + notification.redirect.active && + notification.redirect.type === 'recordIds'; + const recordsIds = []; + const recordListArr = []; + for (const record of records) { + if (record.data) { + Object.keys(record.data).forEach(function (key) { + record.data[key] = + typeof record.data[key] == 'object' + ? record.data[key]?.join(',') + : record.data[key]; + }); + recordListArr.push({ ...record.data, id: record._id }); + if (redirectToRecords) { + recordsIds.push(record._id); + } } } - let d = 0; - for await (const groupRecord of groupRecordArr) { - if (groupRecord.length > 0) { - template.content = await processTemplateContent( - template.content, - notificationType, - fieldArr, - groupRecord - ); - // console.log('template.content: ', template.content); + if (!!userField || !!emailField) { + const field = userField || emailField; + const groupRecordArr = []; + const groupValArr = []; + for (const record of recordListArr) { + const index = groupValArr.indexOf(record[field]); + if (index == -1) { + groupValArr.push(record[field]); + delete record[field]; + groupRecordArr.push([record]); + } else { + delete record[field]; + groupRecordArr[index].push(record); + } } - if (!!userField) { - // If using userField, get the user with the id saved in the record data - const userDetail = await User.findById(groupValArr[d]); - if (!!userDetail && !!userDetail.username) { - if (notificationType === customNotificationType.email) { - // If email type, should get user email - recipients = [userDetail.username]; - await customNotificationSend( - template, - recipients, - notification - ); - } else { - // If notification type, should get user id - recipients = userDetail.id; - await customNotificationSend( - template, - recipients, - notification - ); + + let d = 0; + for await (const groupRecord of groupRecordArr) { + if (groupRecord.length > 0) { + template.content = await processTemplateContent( + template.content, + notificationType, + fieldArr, + groupRecord + ); + } + if (!!userField) { + // If using userField, get the user with the id saved in the record data + const userDetail = await User.findById(groupValArr[d]); + if (!!userDetail && !!userDetail.username) { + if ( + notificationType === customNotificationType.email + ) { + // If email type, should get user email + recipients = [userDetail.username]; + await customNotificationSend( + template, + recipients, + notification + ); + sent = true; + } else { + // If notification type, should get user id + recipients = userDetail.id; + await customNotificationSend( + template, + recipients, + notification, + recordsIds + ); + sent = true; + } } + } else { + // If using emailField, get the email saved in the record data + recipients = groupValArr[d]; + await customNotificationSend( + template, + recipients, + notification + ); + sent = true; } - } else { - // If using emailField, get the email saved in the record data - recipients = groupValArr[d]; - await customNotificationSend( - template, - recipients, - notification - ); + d++; } - d++; + } else { + template.content = await processTemplateContent( + template.content, + notificationType, + fieldArr, + recordListArr + ); + await customNotificationSend( + template, + recipients, + notification, + recordsIds + ); + sent = true; } - } else { - template.content = await processTemplateContent( - template.content, - notificationType, - fieldArr, - recordListArr - ); - // console.log('template.content: ', template.content); - await customNotificationSend( - template, - recipients, - notification - ); } } else { await customNotificationSend( @@ -413,21 +442,24 @@ export const scheduleCustomNotificationJob = async ( recipients, notification ); + sent = true; } - const update = { - $set: { - 'customNotifications.$.lastExecutionStatus': 'success', - 'customNotifications.$.lastExecution': new Date(), - }, - }; - await Application.findOneAndUpdate( - { - _id: application._id, - 'customNotifications._id': notification._id, - }, - update - ); + if (sent) { + const update = { + $set: { + 'customNotifications.$.lastExecutionStatus': 'success', + 'customNotifications.$.lastExecution': new Date(), + }, + }; + await Application.findOneAndUpdate( + { + _id: application._id, + 'customNotifications._id': notification._id, + }, + update + ); + } } catch (error) { logger.error(error.message, { stack: error.stack }); } diff --git a/src/utils/email/gridEmailBuilder.ts b/src/utils/email/gridEmailBuilder.ts index c10fbd24a..712e2a288 100644 --- a/src/utils/email/gridEmailBuilder.ts +++ b/src/utils/email/gridEmailBuilder.ts @@ -179,6 +179,13 @@ export const preprocess = ( text = text.split(Placeholder.NOW).join(nowToString); } + // === NOW === + if (text.includes(Placeholder.RECORD_ID)) { + const textArray: string[] = []; + dataset.rows.forEach((record: any) => textArray.push(record.id)); + text = textArray.join(', '); + } + // === DATASET === if (text.includes(Placeholder.DATASET) && dataset) { if (dataset.fields.length > 0 && dataset.rows.length > 0) { From 4a3828f7d20a70e3982f60557c017099207479c4 Mon Sep 17 00:00:00 2001 From: Estela Silva Date: Mon, 19 Aug 2024 17:45:15 -0300 Subject: [PATCH 13/15] re-factor: triggers filters moved from resource to custom notification modal --- src/models/customNotification.model.ts | 2 + src/models/resource.model.ts | 5 - .../editCustomNotification.mutation.ts | 68 +++++--- src/schema/mutation/editResource.mutation.ts | 55 +----- src/schema/types/customNotification.type.ts | 1 + src/schema/types/resource.type.ts | 1 - src/server/customNotificationScheduler.ts | 163 ++---------------- .../customNotificationSend.ts | 132 ++++++++++++++ 8 files changed, 189 insertions(+), 238 deletions(-) create mode 100644 src/utils/customNotification/customNotificationSend.ts diff --git a/src/models/customNotification.model.ts b/src/models/customNotification.model.ts index 00cfd9162..68af98acd 100644 --- a/src/models/customNotification.model.ts +++ b/src/models/customNotification.model.ts @@ -65,6 +65,7 @@ export const customNotificationSchema = new Schema( onRecordUpdate: Boolean, applicationTrigger: Boolean, redirect: mongoose.Schema.Types.Mixed, + filter: mongoose.Schema.Types.Mixed, }, { timestamps: { createdAt: 'createdAt', updatedAt: 'modifiedAt' }, @@ -91,6 +92,7 @@ export interface CustomNotification extends Document { onRecordCreation?: boolean; onRecordUpdate?: boolean; // record deletion counts as an update applicationTrigger?: boolean; + filter?: any; redirect?: any; // redirect?: { // active: boolean; diff --git a/src/models/resource.model.ts b/src/models/resource.model.ts index 37e50580e..83ecc3bd0 100644 --- a/src/models/resource.model.ts +++ b/src/models/resource.model.ts @@ -65,7 +65,6 @@ export interface Resource extends Document { layouts: any; aggregations: any; importField?: string; - triggersFilters?: any; } /** Mongoose resource schema definition */ @@ -151,10 +150,6 @@ const resourceSchema = new Schema( type: mongoose.Schema.Types.Mixed, default: [], }, - triggersFilters: { - type: [mongoose.Schema.Types.Mixed], - default: [], - }, layouts: [layoutSchema], aggregations: [aggregationSchema], importField: { diff --git a/src/schema/mutation/editCustomNotification.mutation.ts b/src/schema/mutation/editCustomNotification.mutation.ts index 76f5b5af1..b44af7c57 100644 --- a/src/schema/mutation/editCustomNotification.mutation.ts +++ b/src/schema/mutation/editCustomNotification.mutation.ts @@ -16,12 +16,14 @@ import { logger } from '@services/logger.service'; import { graphQLAuthCheck } from '@schema/shared'; import { Types } from 'mongoose'; import { Context } from '@server/apollo/context'; +import GraphQLJSON from 'graphql-type-json'; /** Arguments for the editCustomNotification mutation */ type EditCustomNotificationArgs = { id: string | Types.ObjectId; application: string; - notification: CustomNotificationArgs; + notification?: CustomNotificationArgs; + triggersFilters?: any; }; /** @@ -32,7 +34,8 @@ export default { args: { id: { type: new GraphQLNonNull(GraphQLID) }, application: { type: new GraphQLNonNull(GraphQLID) }, - notification: { type: new GraphQLNonNull(CustomNotificationInputType) }, + notification: { type: CustomNotificationInputType }, + triggersFilters: { type: GraphQLJSON }, }, async resolve(_, args: EditCustomNotificationArgs, context: Context) { graphQLAuthCheck(context); @@ -60,29 +63,38 @@ export default { } } // Save custom notification in application - const update = { - $set: { - 'customNotifications.$.name': args.notification.name, - 'customNotifications.$.description': args.notification.description, - 'customNotifications.$.schedule': args.notification.schedule, - 'customNotifications.$.notificationType': - args.notification.notificationType, - 'customNotifications.$.resource': args.notification.resource, - 'customNotifications.$.layout': args.notification.layout, - 'customNotifications.$.template': args.notification.template, - 'customNotifications.$.recipients': args.notification.recipients, - 'customNotifications.$.status': args.notification.status, - 'customNotifications.$.recipientsType': - args.notification.recipientsType, - 'customNotifications.$.onRecordCreation': - args.notification.onRecordCreation, - 'customNotifications.$.onRecordUpdate': - args.notification.onRecordUpdate, - 'customNotifications.$.applicationTrigger': - args.notification.applicationTrigger, - 'customNotifications.$.redirect': args.notification.redirect, - }, - }; + let update = {}; + if (args.triggersFilters) { + update = { + $set: { + 'customNotifications.$.filter': args.triggersFilters, + }, + }; + } else { + update = { + $set: { + 'customNotifications.$.name': args.notification.name, + 'customNotifications.$.description': args.notification.description, + 'customNotifications.$.schedule': args.notification.schedule, + 'customNotifications.$.notificationType': + args.notification.notificationType, + 'customNotifications.$.resource': args.notification.resource, + 'customNotifications.$.layout': args.notification.layout, + 'customNotifications.$.template': args.notification.template, + 'customNotifications.$.recipients': args.notification.recipients, + 'customNotifications.$.status': args.notification.status, + 'customNotifications.$.recipientsType': + args.notification.recipientsType, + 'customNotifications.$.onRecordCreation': + args.notification.onRecordCreation, + 'customNotifications.$.onRecordUpdate': + args.notification.onRecordUpdate, + 'customNotifications.$.applicationTrigger': + args.notification.applicationTrigger, + 'customNotifications.$.redirect': args.notification.redirect, + }, + }; + } const application = await Application.findOneAndUpdate( { _id: args.application, 'customNotifications._id': args.id }, @@ -94,8 +106,10 @@ export default { (customNotification) => customNotification.id.toString() === args.id ); if ( - args.notification.schedule && - args.notification.status === customNotificationStatus.active + (args.notification?.schedule && + args.notification?.status === customNotificationStatus.active) || + (args.triggersFilters && + notificationDetail.status === customNotificationStatus.active) ) { scheduleCustomNotificationJob(notificationDetail, application); } else { diff --git a/src/schema/mutation/editResource.mutation.ts b/src/schema/mutation/editResource.mutation.ts index 4e0986470..f73839b3a 100644 --- a/src/schema/mutation/editResource.mutation.ts +++ b/src/schema/mutation/editResource.mutation.ts @@ -13,7 +13,7 @@ import { getExpressionFromString, OperationTypeMap, } from '@utils/aggregation/expressionFromString'; -import { Application, DefaultIncrementalIdShapeT, Resource } from '@models'; +import { DefaultIncrementalIdShapeT, Resource } from '@models'; import { AppAbility } from '@security/defineUserAbility'; import { get, has, isArray, isEqual, isNil } from 'lodash'; import { logger } from '@services/logger.service'; @@ -21,8 +21,6 @@ import { graphQLAuthCheck } from '@schema/shared'; import { Context } from '@server/apollo/context'; import { IdShapeType } from '@schema/inputs/id-shape.input'; import buildCalculatedFieldPipeline from '@utils/aggregation/buildCalculatedFieldPipeline'; -import { customNotificationStatus } from '@const/enumTypes'; -import { scheduleCustomNotificationJob } from '@server/customNotificationScheduler'; /** Simple resource permission change type */ type SimplePermissionChange = @@ -569,7 +567,6 @@ type EditResourceArgs = { permissions?: any; fieldsPermissions?: any; calculatedField?: any; - triggersFilters?: any; idShape?: DefaultIncrementalIdShapeT; importField?: string; }; @@ -586,7 +583,6 @@ export default { permissions: { type: GraphQLJSON }, fieldsPermissions: { type: GraphQLJSON }, calculatedField: { type: GraphQLJSON }, - triggersFilters: { type: GraphQLJSON }, idShape: { type: IdShapeType }, importField: { type: GraphQLString }, }, @@ -601,7 +597,6 @@ export default { !args.calculatedField && !args.fieldsPermissions && !args.idShape && - !args.triggersFilters && !args.importField) ) { throw new GraphQLError( @@ -844,30 +839,6 @@ export default { } } - if (args.triggersFilters) { - let triggersFilters = [args.triggersFilters]; - if (resource.triggersFilters.length) { - triggersFilters = [...resource.triggersFilters]; - const index = resource.triggersFilters.findIndex( - (tg: any) => tg.application === args.triggersFilters.application - ); - if (index !== -1) { - triggersFilters[index] = args.triggersFilters; - } else { - triggersFilters.push(args.triggersFilters); - } - } - if (update.$set) { - Object.assign(update.$set, { - ['triggersFilters']: triggersFilters, - }); - } else { - Object.assign(update, { - $set: { ['triggersFilters']: triggersFilters }, - }); - } - } - // Split the request in three parts, to avoid conflict if (!!update.$set) { await Resource.findByIdAndUpdate(args.id, { $set: update.$set }); @@ -881,30 +852,6 @@ export default { await updateIncrementalIds(resource, args.idShape); } - // Make sure that filters changes are applied in the scheduled notifications - if (args.triggersFilters) { - const application = await Application.findById( - args.triggersFilters.application - ).populate({ - path: 'customNotifications', - model: 'CustomNotification', - }); - const filteredNotifications = application.customNotifications.filter( - (notification) => - notification.applicationTrigger === true && - notification.resource.equals(args.id) - ); - filteredNotifications.forEach((notification) => { - if ( - notification.schedule && - notification.applicationTrigger && - notification.status === customNotificationStatus.active - ) { - scheduleCustomNotificationJob(notification, application); - } - }); - } - const updatedResource = await Resource.findByIdAndUpdate( args.id, update.$addToSet ? { $addToSet: update.$addToSet } : {}, diff --git a/src/schema/types/customNotification.type.ts b/src/schema/types/customNotification.type.ts index 2c832cf58..94720a0f5 100644 --- a/src/schema/types/customNotification.type.ts +++ b/src/schema/types/customNotification.type.ts @@ -36,6 +36,7 @@ export const CustomNotificationType = new GraphQLObjectType({ onRecordCreation: { type: GraphQLBoolean }, onRecordUpdate: { type: GraphQLBoolean }, applicationTrigger: { type: GraphQLBoolean }, + filter: { type: GraphQLJSON }, redirect: { type: GraphQLJSON }, }), }); diff --git a/src/schema/types/resource.type.ts b/src/schema/types/resource.type.ts index 613042963..91b340b35 100644 --- a/src/schema/types/resource.type.ts +++ b/src/schema/types/resource.type.ts @@ -395,7 +395,6 @@ export const ResourceType = new GraphQLObjectType({ return getMetaData(parent, context); }, }, - triggersFilters: { type: GraphQLJSON }, }), }); diff --git a/src/server/customNotificationScheduler.ts b/src/server/customNotificationScheduler.ts index bdffdd432..6914bea85 100644 --- a/src/server/customNotificationScheduler.ts +++ b/src/server/customNotificationScheduler.ts @@ -3,21 +3,19 @@ import { CustomNotification, Resource, Record as RecordModel, - Notification, User, - Channel, } from '@models'; import { CronJob } from 'cron'; import { logger } from '../services/logger.service'; import * as cronValidator from 'cron-validator'; import get from 'lodash/get'; -import { sendEmail, preprocess } from '@utils/email'; +import { preprocess } from '@utils/email'; import { customNotificationRecipientsType, customNotificationType, } from '@const/enumTypes'; -import pubsub from './pubsub'; import getFilter from '@utils/schema/resolvers/Query/getFilter'; +import customNotificationSend from '@utils/customNotification/customNotificationSend'; /** A map with the custom notification ids as keys and the scheduled custom notification as values */ const customNotificationMap: Record = {}; @@ -41,34 +39,6 @@ const customNotificationScheduler = async () => { export default customNotificationScheduler; -/** - * Send email for custom notification - * - * @param template processed email template - * @param recipients custom notification recipients - * @param notification custom notification - */ -const customNotificationMailSend = async ( - template, - recipients, - notification -) => { - if (!!template && recipients.length > 0) { - await sendEmail({ - message: { - to: recipients, - subject: template.content.subject, - html: template.content.body, - attachments: [], - }, - }); - } else { - throw new Error( - `[${notification.name}] notification email template not available or recipients not available:` - ); - } -}; - /** * Depending on notification type, for custom notification * @@ -105,107 +75,6 @@ const processTemplateContent = async ( return content; }; -/** - * Send email for custom notification - * - * @param template processed email template - * @param recipients custom notification recipients (form id or users from user field) - * @param notification custom notification - * @param recordsIds records ids list (if any) - */ -const customNotificationNotificationSend = async ( - template, - recipients, - notification, - recordsIds -) => { - if (!!template && !!recipients) { - const redirect = - notification.redirect && notification.redirect.active - ? { - ...notification.redirect, - recordIds: recordsIds, - layout: notification.layout, - resource: notification.resource, - } - : null; - if ( - notification.recipientsType === customNotificationRecipientsType.channel - ) { - // Send notification to channel - const channel = await Channel.findById(recipients[0]); - if (channel) { - const notificationInstance = new Notification({ - action: template.content.title, - content: template.content.description, - //createdAt: new Date(), - channel: channel.id, - seenBy: [], - redirect, - }); - await notificationInstance.save(); - const publisher = await pubsub(); - publisher.publish(channel.id, { notificationInstance }); - } - } else if ( - notification.recipientsType === customNotificationRecipientsType.userField - ) { - // Send notification to a user - const notificationInstance = new Notification({ - action: template.content.title, - content: template.content.description, - //createdAt: new Date(), - user: recipients, - seenBy: [], - redirect, - }); - await notificationInstance.save(); - const publisher = await pubsub(); - publisher.publish(recipients, { notificationInstance }); - } - } else { - throw new Error( - `[${notification.name}] notification template not available or recipients not available:` - ); - } -}; - -/** - * Prepare custom notification to be sent by type (email or notification) - * - * @param template processed email template - * @param recipients custom notification recipients (always a array) - * (can be a single email, a list of emails, a channel id or users from a user field) - * @param notification custom notification - * @param recordsIds records ids list - */ -const customNotificationSend = async ( - template, - recipients, - notification, - recordsIds?: string[] -) => { - if (!!template && recipients.length > 0) { - const notificationType = get(notification, 'notificationType', 'email'); - if (notificationType === customNotificationType.email) { - // If custom notification type is email - await customNotificationMailSend(template, recipients, notification); - } else { - // If custom notification type is notification - await customNotificationNotificationSend( - template, - recipients, - notification, - recordsIds - ); - } - } else { - throw new Error( - `[${notification.name}] notification template not available or recipients not available:` - ); - } -}; - /** * Schedule or re-schedule a custom notification. * @@ -299,26 +168,18 @@ export const scheduleCustomNotificationJob = async ( fieldArr.push(obj); } } - // If triggers, check if has filters + // If triggers check if has filters let mongooseFilter = {}; - if (notification.applicationTrigger) { - const triggersFilters = resource.triggersFilters.find( - (tg: any) => tg.application === application.id + if ( + notification.applicationTrigger && + notification.filter?.filters?.length + ) { + // TODO: take into account resources questions + // Filter from the query definition + mongooseFilter = getFilter( + notification.filter, + resource.fields ); - if ( - triggersFilters && - Object.prototype.hasOwnProperty.call( - triggersFilters, - 'cronBased' - ) - ) { - // TODO: take into account resources questions - // Filter from the query definition - mongooseFilter = getFilter( - { ...triggersFilters.cronBased, logic: 'and' }, - resource.fields - ); - } } const records = await RecordModel.aggregate([ { diff --git a/src/utils/customNotification/customNotificationSend.ts b/src/utils/customNotification/customNotificationSend.ts new file mode 100644 index 000000000..14cc4fb9a --- /dev/null +++ b/src/utils/customNotification/customNotificationSend.ts @@ -0,0 +1,132 @@ +import { + customNotificationRecipientsType, + customNotificationType, +} from '@const/enumTypes'; +import { Channel, Notification } from '@models'; +import pubsub from '@server/pubsub'; +import { sendEmail } from '@utils/email'; +import { get } from 'lodash'; + +/** + * Send email for custom notification + * + * @param template processed email template + * @param recipients custom notification recipients + * @param notification custom notification + */ +const customNotificationMailSend = async ( + template, + recipients, + notification +) => { + if (!!template && recipients.length > 0) { + await sendEmail({ + message: { + to: recipients, + subject: template.content.subject, + html: template.content.body, + attachments: [], + }, + }); + } else { + throw new Error( + `[${notification.name}] notification email template not available or recipients not available:` + ); + } +}; + +/** + * Send email for custom notification + * + * @param template processed email template + * @param recipients custom notification recipients (form id or users from user field) + * @param notification custom notification + * @param recordsIds records ids list (if any) + */ +const notificationSend = async ( + template, + recipients, + notification, + recordsIds +) => { + if (!!template && !!recipients) { + const redirect = + notification.redirect && notification.redirect.active + ? { + ...notification.redirect, + recordIds: recordsIds, + layout: notification.layout, + resource: notification.resource, + } + : null; + if ( + notification.recipientsType === customNotificationRecipientsType.channel + ) { + // Send notification to channel + const channel = await Channel.findById(recipients[0]); + if (channel) { + const notificationInstance = new Notification({ + action: template.content.title, + content: template.content.description, + //createdAt: new Date(), + channel: channel.id, + seenBy: [], + redirect, + }); + await notificationInstance.save(); + const publisher = await pubsub(); + publisher.publish(channel.id, { notificationInstance }); + } + } else if ( + notification.recipientsType === customNotificationRecipientsType.userField + ) { + // Send notification to a user + const notificationInstance = new Notification({ + action: template.content.title, + content: template.content.description, + //createdAt: new Date(), + user: recipients, + seenBy: [], + redirect, + }); + await notificationInstance.save(); + const publisher = await pubsub(); + publisher.publish(recipients, { notificationInstance }); + } + } else { + throw new Error( + `[${notification.name}] notification template not available or recipients not available:` + ); + } +}; + +/** + * Prepare custom notification to be sent by type (email or notification) + * + * @param template processed email template + * @param recipients custom notification recipients (always a array) + * (can be a single email, a list of emails, a channel id or users from a user field) + * @param notification custom notification + * @param recordsIds records ids list + */ +export default async ( + template, + recipients, + notification, + recordsIds?: string[] +) => { + if (!!template && recipients.length > 0) { + const notificationType = get(notification, 'notificationType', 'email'); + if (notificationType === customNotificationType.email) { + // If custom notification type is email + await customNotificationMailSend(template, recipients, notification); + } else { + // If custom notification type is notification + await notificationSend(template, recipients, notification, recordsIds); + } + } else { + throw new Error( + `[${notification.name}] notification template not available or recipients not available:` + ); + } +}; From d04877bc8ceb0624cda8617cf7c52df40e64deb7 Mon Sep 17 00:00:00 2001 From: Estela Silva Date: Tue, 20 Aug 2024 16:40:37 -0300 Subject: [PATCH 14/15] wip: re-factor custom notification schdeduler to DRY --- .../customNotification/getTriggerFilter.ts | 20 ++ .../processCustomNotification.ts | 254 ++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 src/utils/customNotification/getTriggerFilter.ts create mode 100644 src/utils/customNotification/processCustomNotification.ts diff --git a/src/utils/customNotification/getTriggerFilter.ts b/src/utils/customNotification/getTriggerFilter.ts new file mode 100644 index 000000000..49f2b32e0 --- /dev/null +++ b/src/utils/customNotification/getTriggerFilter.ts @@ -0,0 +1,20 @@ +import { CustomNotification, Resource } from '@models'; +import getFilter from '@utils/schema/resolvers/Query/getFilter'; + +/** + * Check if trigger has filters, if so return mongoose filter + * + * @param notification custom notification + * @param resource resource object + * @returns mongoose filter or empty object + */ +export default (notification: CustomNotification, resource: Resource) => { + let mongooseFilter = {}; + // If triggers check if has filters + if (notification.applicationTrigger && notification.filter?.filters?.length) { + // TODO: take into account resources questions + // Filter from the query definition + mongooseFilter = getFilter(notification.filter, resource.fields); + } + return mongooseFilter; +}; diff --git a/src/utils/customNotification/processCustomNotification.ts b/src/utils/customNotification/processCustomNotification.ts new file mode 100644 index 000000000..478086c61 --- /dev/null +++ b/src/utils/customNotification/processCustomNotification.ts @@ -0,0 +1,254 @@ +import { + customNotificationRecipientsType, + customNotificationType, +} from '@const/enumTypes'; +import { + CustomNotification, + Resource, + Record, + Application, + User, +} from '@models'; +import { get } from 'lodash'; +import customNotificationSend from './customNotificationSend'; +import { logger } from '@services/logger.service'; +import { preprocess } from '@utils/email'; + +/** + * Depending on notification type, for custom notification + * + * @param content template content + * @param notificationType notification type + * @param fields fields to process + * @param rows data of records rows + */ +const processTemplateContent = async ( + content, + notificationType, + fields, + rows +) => { + if (notificationType === customNotificationType.email) { + content.body = await preprocess(content.body, { + fields, + rows, + }); + content.subject = await preprocess(content.subject, { + fields, + rows, + }); + } else { + content.title = await preprocess(content.title, { + fields, + rows, + }); + content.description = await preprocess(content.description, { + fields, + rows, + }); + } + return content; +}; + +/** + * Check if trigger has filters, if so return mongoose filter + * + * @param notification custom notification + * @param application custom notification's application + * @param resource resource object + * @param records records object + */ +export default async ( + notification: CustomNotification, + application: Application, + resource?: Resource, + records?: Record[] +) => { + try { + const template = application.templates.find( + (x) => x._id.toString() === notification.template.toString() + ); + + const notificationType = get(notification, 'notificationType', 'email'); + + let sent = false; + let recipients: string[] = []; + let userField = ''; + let emailField = ''; + switch (notification.recipientsType) { + // Use single email as recipients + case customNotificationRecipientsType.email: { + recipients = [notification.recipients]; + break; + } + // Use distribution list as recipients + case customNotificationRecipientsType.distributionList: { + const distribution = application.distributionLists.find( + (x) => x._id.toString() === notification.recipients + ); + recipients = get(distribution, 'emails', []); + break; + } + // Use dataset user question field as recipients + case customNotificationRecipientsType.userField: { + userField = notification.recipients; + break; + } + // Use dataset email question field as recipients + case customNotificationRecipientsType.emailField: { + emailField = notification.recipients; + break; + } + // Use channel as recipients + case customNotificationRecipientsType.channel: { + recipients = [notification.recipients]; + break; + } + } + + if (resource) { + const layout = resource.layouts.find( + (x) => x._id.toString() === notification.layout.toString() + ); + + const fieldArr = []; + for (const field of resource.fields) { + const layoutField = layout.query.fields.find( + (fieldDetail) => fieldDetail.name == field.name + ); + + if (field.type != 'users') { + const obj = { + name: field.name, + field: field.name, + type: field.type, + meta: { + field: field, + }, + title: layoutField?.label || layoutField?.name, + }; + fieldArr.push(obj); + } + } + + if (records.length) { + const redirectToRecords = + notification.redirect && + notification.redirect.active && + notification.redirect.type === 'recordIds'; + const recordsIds = []; + const recordListArr = []; + for (const record of records) { + if (record.data) { + Object.keys(record.data).forEach(function (key) { + record.data[key] = + typeof record.data[key] == 'object' + ? record.data[key]?.join(',') + : record.data[key]; + }); + recordListArr.push({ ...record.data, id: record._id }); + if (redirectToRecords) { + recordsIds.push(record._id); + } + } + } + + if (!!userField || !!emailField) { + const field = userField || emailField; + const groupRecordArr = []; + const groupValArr = []; + for (const record of recordListArr) { + const index = groupValArr.indexOf(record[field]); + if (index == -1) { + groupValArr.push(record[field]); + delete record[field]; + groupRecordArr.push([record]); + } else { + delete record[field]; + groupRecordArr[index].push(record); + } + } + + let d = 0; + for await (const groupRecord of groupRecordArr) { + if (groupRecord.length > 0) { + template.content = await processTemplateContent( + template.content, + notificationType, + fieldArr, + groupRecord + ); + } + if (!!userField) { + // If using userField, get the user with the id saved in the record data + const userDetail = await User.findById(groupValArr[d]); + if (!!userDetail && !!userDetail.username) { + if (notificationType === customNotificationType.email) { + // If email type, should get user email + recipients = [userDetail.username]; + await customNotificationSend( + template, + recipients, + notification + ); + sent = true; + } else { + // If notification type, should get user id + recipients = userDetail.id; + await customNotificationSend( + template, + recipients, + notification, + recordsIds + ); + sent = true; + } + } + } else { + // If using emailField, get the email saved in the record data + recipients = groupValArr[d]; + await customNotificationSend(template, recipients, notification); + sent = true; + } + d++; + } + } else { + template.content = await processTemplateContent( + template.content, + notificationType, + fieldArr, + recordListArr + ); + await customNotificationSend( + template, + recipients, + notification, + recordsIds + ); + sent = true; + } + } + } else { + await customNotificationSend(template, recipients, notification); + sent = true; + } + + if (sent) { + const update = { + $set: { + 'customNotifications.$.lastExecutionStatus': 'success', + 'customNotifications.$.lastExecution': new Date(), + }, + }; + await Application.findOneAndUpdate( + { + _id: application._id, + 'customNotifications._id': notification._id, + }, + update + ); + } + } catch (error) { + logger.error(error.message, { stack: error.stack }); + } +}; From 4632fbc7951d1ff2db75e75e82857a42de60b3dc Mon Sep 17 00:00:00 2001 From: Estela Silva Date: Fri, 23 Aug 2024 09:50:46 -0300 Subject: [PATCH 15/15] added record watch to active triggers --- src/server/customNotificationScheduler.ts | 259 +--------------------- src/server/index.ts | 62 +++++- 2 files changed, 72 insertions(+), 249 deletions(-) diff --git a/src/server/customNotificationScheduler.ts b/src/server/customNotificationScheduler.ts index 6914bea85..287931351 100644 --- a/src/server/customNotificationScheduler.ts +++ b/src/server/customNotificationScheduler.ts @@ -3,19 +3,13 @@ import { CustomNotification, Resource, Record as RecordModel, - User, } from '@models'; import { CronJob } from 'cron'; import { logger } from '../services/logger.service'; import * as cronValidator from 'cron-validator'; import get from 'lodash/get'; -import { preprocess } from '@utils/email'; -import { - customNotificationRecipientsType, - customNotificationType, -} from '@const/enumTypes'; -import getFilter from '@utils/schema/resolvers/Query/getFilter'; -import customNotificationSend from '@utils/customNotification/customNotificationSend'; +import getTriggerFilter from '@utils/customNotification/getTriggerFilter'; +import processCustomNotification from '@utils/customNotification/processCustomNotification'; /** A map with the custom notification ids as keys and the scheduled custom notification as values */ const customNotificationMap: Record = {}; @@ -39,42 +33,6 @@ const customNotificationScheduler = async () => { export default customNotificationScheduler; -/** - * Depending on notification type, for custom notification - * - * @param content template content - * @param notificationType notification type - * @param fields fields to process - * @param rows data of records rows - */ -const processTemplateContent = async ( - content, - notificationType, - fields, - rows -) => { - if (notificationType === customNotificationType.email) { - content.body = await preprocess(content.body, { - fields, - rows, - }); - content.subject = await preprocess(content.subject, { - fields, - rows, - }); - } else { - content.title = await preprocess(content.title, { - fields, - rows, - }); - content.description = await preprocess(content.description, { - fields, - rows, - }); - } - return content; -}; - /** * Schedule or re-schedule a custom notification. * @@ -97,90 +55,12 @@ export const scheduleCustomNotificationJob = async ( notification.schedule, async () => { try { - const template = application.templates.find( - (x) => x._id.toString() === notification.template.toString() - ); - - const notificationType = get( - notification, - 'notificationType', - 'email' - ); - let sent = false; - let recipients: string[] = []; - let userField = ''; - let emailField = ''; - switch (notification.recipientsType) { - // Use single email as recipients - case customNotificationRecipientsType.email: { - recipients = [notification.recipients]; - break; - } - // Use distribution list as recipients - case customNotificationRecipientsType.distributionList: { - const distribution = application.distributionLists.find( - (x) => x._id.toString() === notification.recipients - ); - recipients = get(distribution, 'emails', []); - break; - } - // Use dataset user question field as recipients - case customNotificationRecipientsType.userField: { - userField = notification.recipients; - break; - } - // Use dataset email question field as recipients - case customNotificationRecipientsType.emailField: { - emailField = notification.recipients; - break; - } - // Use channel as recipients - case customNotificationRecipientsType.channel: { - recipients = [notification.recipients]; - break; - } - } - const resource = await Resource.findOne({ _id: notification.resource, }); if (resource) { - const layout = resource.layouts.find( - (x) => x._id.toString() === notification.layout.toString() - ); - - const fieldArr = []; - for (const field of resource.fields) { - const layoutField = layout.query.fields.find( - (fieldDetail) => fieldDetail.name == field.name - ); - - if (field.type != 'users') { - const obj = { - name: field.name, - field: field.name, - type: field.type, - meta: { - field: field, - }, - title: layoutField?.label || layoutField?.name, - }; - fieldArr.push(obj); - } - } // If triggers check if has filters - let mongooseFilter = {}; - if ( - notification.applicationTrigger && - notification.filter?.filters?.length - ) { - // TODO: take into account resources questions - // Filter from the query definition - mongooseFilter = getFilter( - notification.filter, - resource.fields - ); - } + const mongooseFilter = getTriggerFilter(notification, resource); const records = await RecordModel.aggregate([ { $match: { @@ -193,133 +73,16 @@ export const scheduleCustomNotificationJob = async ( }, }, ]); - - if (records) { - const redirectToRecords = - notification.redirect && - notification.redirect.active && - notification.redirect.type === 'recordIds'; - const recordsIds = []; - const recordListArr = []; - for (const record of records) { - if (record.data) { - Object.keys(record.data).forEach(function (key) { - record.data[key] = - typeof record.data[key] == 'object' - ? record.data[key]?.join(',') - : record.data[key]; - }); - recordListArr.push({ ...record.data, id: record._id }); - if (redirectToRecords) { - recordsIds.push(record._id); - } - } - } - - if (!!userField || !!emailField) { - const field = userField || emailField; - const groupRecordArr = []; - const groupValArr = []; - for (const record of recordListArr) { - const index = groupValArr.indexOf(record[field]); - if (index == -1) { - groupValArr.push(record[field]); - delete record[field]; - groupRecordArr.push([record]); - } else { - delete record[field]; - groupRecordArr[index].push(record); - } - } - - let d = 0; - for await (const groupRecord of groupRecordArr) { - if (groupRecord.length > 0) { - template.content = await processTemplateContent( - template.content, - notificationType, - fieldArr, - groupRecord - ); - } - if (!!userField) { - // If using userField, get the user with the id saved in the record data - const userDetail = await User.findById(groupValArr[d]); - if (!!userDetail && !!userDetail.username) { - if ( - notificationType === customNotificationType.email - ) { - // If email type, should get user email - recipients = [userDetail.username]; - await customNotificationSend( - template, - recipients, - notification - ); - sent = true; - } else { - // If notification type, should get user id - recipients = userDetail.id; - await customNotificationSend( - template, - recipients, - notification, - recordsIds - ); - sent = true; - } - } - } else { - // If using emailField, get the email saved in the record data - recipients = groupValArr[d]; - await customNotificationSend( - template, - recipients, - notification - ); - sent = true; - } - d++; - } - } else { - template.content = await processTemplateContent( - template.content, - notificationType, - fieldArr, - recordListArr - ); - await customNotificationSend( - template, - recipients, - notification, - recordsIds - ); - sent = true; - } + if (records.length) { + await processCustomNotification( + notification, + application, + resource, + records + ); } } else { - await customNotificationSend( - template, - recipients, - notification - ); - sent = true; - } - - if (sent) { - const update = { - $set: { - 'customNotifications.$.lastExecutionStatus': 'success', - 'customNotifications.$.lastExecution': new Date(), - }, - }; - await Application.findOneAndUpdate( - { - _id: application._id, - 'customNotifications._id': notification._id, - }, - update - ); + await processCustomNotification(notification, application); } } catch (error) { logger.error(error.message, { stack: error.stack }); diff --git a/src/server/index.ts b/src/server/index.ts index f59a8f97b..482d312f1 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -15,7 +15,7 @@ import Backend from 'i18next-node-fs-backend'; import i18nextMiddleware from 'i18next-http-middleware'; import { logger } from '../services/logger.service'; import { winstonLogger } from './middlewares/winston'; -import { Form, ReferenceData, Resource } from '@models'; +import { Application, Form, Record, ReferenceData, Resource } from '@models'; import buildSchema from '@utils/schema/buildSchema'; import { GraphQLSchema } from 'graphql'; import context, { Context } from './apollo/context'; @@ -29,6 +29,9 @@ import { } from './apollo/queries/introspection.query'; import { pluralize } from 'inflection'; import config from 'config'; +import getTriggerFilter from '@utils/customNotification/getTriggerFilter'; +import { isEqual } from 'lodash'; +import processCustomNotification from '@utils/customNotification/processCustomNotification'; /** List of user fields */ const USER_FIELDS = ['id', 'name', 'username']; @@ -121,6 +124,63 @@ class SafeServer { } } }); + + // Watch records creation and updates to see if should emit trigger notification + Record.watch().on('change', async (data) => { + const recordId = data.documentKey._id; + const record = await Record.findById(recordId); + + const type = + data.operationType === 'update' ? 'onRecordUpdate' : 'onRecordCreation'; + // Get all applications with custom notifications for the record resource + const applications = await Application.find({ + customNotifications: { + $exists: true, + $type: 'array', + $ne: [], + $elemMatch: { + applicationTrigger: true, + status: 'active', + resource: record.resource, + ...(type === 'onRecordUpdate' && { onRecordUpdate: true }), + ...(type === 'onRecordCreation' && { onRecordCreation: true }), + }, + }, + }).populate({ + path: 'customNotifications', + model: 'CustomNotification', + }); + + if (applications) { + // Get record resource details + const resource = await Resource.findById(record.resource); + // Get the triggers and filters of each application, to check if exists and if should send trigger notification/email + for (const application of applications) { + const triggers = application.customNotifications.filter( + (trigger) => + trigger.applicationTrigger && + trigger[type] && + isEqual(trigger.resource, record.resource) + ); + for (const trigger of triggers) { + // For each triggers, get trigger filter + const mongooseFilter = getTriggerFilter(trigger, resource); + // And see if record that triggered watch() should emit notification + const recordFiltered = await Record.find({ + $and: [mongooseFilter, { _id: recordId }], + }); + if (recordFiltered.length) { + processCustomNotification( + trigger, + application, + resource, + recordFiltered + ); + } + } + } + } + }); } /**