diff --git a/src/const/enumTypes.ts b/src/const/enumTypes.ts index 65ee1d0ca..e5402345b 100644 --- a/src/const/enumTypes.ts +++ b/src/const/enumTypes.ts @@ -99,7 +99,9 @@ export const customNotificationStatus = { export const customNotificationRecipientsType = { email: 'email', userField: 'userField', + emailField: 'userField', distributionList: 'distributionList', + channel: 'channel', }; /** @@ -107,6 +109,7 @@ export const customNotificationRecipientsType = { */ export const customNotificationType = { email: 'email', + notification: 'notification', }; /** 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/customNotification.model.ts b/src/models/customNotification.model.ts index c834ca854..68af98acd 100644 --- a/src/models/customNotification.model.ts +++ b/src/models/customNotification.model.ts @@ -61,6 +61,11 @@ export const customNotificationSchema = new Schema( default: customNotificationLastExecutionStatus.pending, required: true, }, + onRecordCreation: Boolean, + onRecordUpdate: Boolean, + applicationTrigger: Boolean, + redirect: mongoose.Schema.Types.Mixed, + filter: mongoose.Schema.Types.Mixed, }, { timestamps: { createdAt: 'createdAt', updatedAt: 'modifiedAt' }, @@ -72,7 +77,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 +89,15 @@ 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; + 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 90a41697b..7c2838a6f 100644 --- a/src/models/notification.model.ts +++ b/src/models/notification.model.ts @@ -9,12 +9,16 @@ 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', + }, + redirect: mongoose.Schema.Types.Mixed, }, { timestamps: { createdAt: 'createdAt', updatedAt: 'modifiedAt' }, @@ -34,8 +38,27 @@ export interface Notification extends Document { createdAt: Date; channel: any; seenBy: any[]; + user?: any; + redirect?: any; + // redirect?: { + // active: boolean; + // type: string; // 'url' | 'recordIds' + // url?: string; + // recordIds?: string[]; + // layout?: string; + // resource?: string; + // }; } +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/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/inputs/customNotification.input.ts b/src/schema/inputs/customNotification.input.ts index e619c3b6b..3456fec35 100644 --- a/src/schema/inputs/customNotification.input.ts +++ b/src/schema/inputs/customNotification.input.ts @@ -3,22 +3,32 @@ import { GraphQLNonNull, GraphQLString, GraphQLID, + GraphQLBoolean, } from 'graphql'; +import GraphQLJSON from 'graphql-type-json'; import { Types } from 'mongoose'; /** Custom Notification type for queries/mutations argument */ 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; - // eslint-disable-next-line @typescript-eslint/naming-convention - notification_status?: string; + onRecordCreation?: boolean; + onRecordUpdate?: boolean; + applicationTrigger?: boolean; + status?: string; + redirect?: { + active: boolean; + type: string; // 'url' | 'recordIds' + url?: string; + recordIds?: string[]; + }; }; /** GraphQL custom notification query input type definition */ @@ -28,13 +38,17 @@ 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) }, - // notification_status: { type: new GraphQLNonNull(GraphQLString) }, + onRecordCreation: { type: GraphQLBoolean }, + 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 d88a26d0d..98614b930 100644 --- a/src/schema/mutation/addCustomNotification.mutation.ts +++ b/src/schema/mutation/addCustomNotification.mutation.ts @@ -65,8 +65,12 @@ 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, + applicationTrigger: args.notification.applicationTrigger, + redirect: args.notification.redirect, }, }, }; @@ -78,8 +82,8 @@ export default { ); const notificationDetail = application.customNotifications.pop(); if ( - args.notification.notification_status === - customNotificationStatus.active + args.notification.schedule && + 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 7ecabbd79..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,22 +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.notification_status, - 'customNotifications.$.recipientsType': - args.notification.recipientsType, - }, - }; + 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 }, @@ -87,8 +106,10 @@ export default { (customNotification) => customNotification.id.toString() === args.id ); if ( - args.notification.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 3a668cd47..f73839b3a 100644 --- a/src/schema/mutation/editResource.mutation.ts +++ b/src/schema/mutation/editResource.mutation.ts @@ -852,11 +852,12 @@ export default { await updateIncrementalIds(resource, args.idShape); } - return await Resource.findByIdAndUpdate( + 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; 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, }, }; 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 3188cfb47..94720a0f5 100644 --- a/src/schema/types/customNotification.type.ts +++ b/src/schema/types/customNotification.type.ts @@ -33,6 +33,11 @@ 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 }, + redirect: { type: GraphQLJSON }, }), }); diff --git a/src/schema/types/notification.type.ts b/src/schema/types/notification.type.ts index 7c4257a78..44ac1effe 100644 --- a/src/schema/types/notification.type.ts +++ b/src/schema/types/notification.type.ts @@ -42,6 +42,8 @@ export const NotificationType = new GraphQLObjectType({ return users; }, }, + user: { type: UserType }, + redirect: { type: GraphQLJSON }, }), }); 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/schema/types/resource.type.ts b/src/schema/types/resource.type.ts index 944510141..91b340b35 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,29 @@ 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', + }); + const filteredNotifications = application.customNotifications.filter( + (notification) => + notification.applicationTrigger === true && + notification.resource.equals(parent._id) + ); + return filteredNotifications ?? []; + } + return []; + }, + }, fields: { type: GraphQLJSON }, canCreateRecords: { type: GraphQLBoolean, @@ -285,6 +309,12 @@ export const ResourceType = new GraphQLObjectType({ return ability.can('delete', parent); }, }, + hasLayouts: { + type: GraphQLBoolean, + resolve(parent) { + return parent.layouts?.length; + }, + }, layouts: { type: LayoutConnectionType, args: { diff --git a/src/server/customNotificationScheduler.ts b/src/server/customNotificationScheduler.ts index 297e57d2b..287931351 100644 --- a/src/server/customNotificationScheduler.ts +++ b/src/server/customNotificationScheduler.ts @@ -3,14 +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 { sendEmail, preprocess } from '@utils/email'; -import { customNotificationRecipientsType } from '@const/enumTypes'; +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 = {}; @@ -19,49 +18,21 @@ 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; -/** - * 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.name, - html: template.content, - attachments: [], - }, - }); - } else { - throw new Error( - `[${notification.name}] notification email template not available or recipients not available:` - ); - } -}; - /** * Schedule or re-schedule a custom notification. * @@ -78,162 +49,59 @@ export const scheduleCustomNotificationJob = async ( 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() - ); - - 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, - }; - fieldArr.push(obj); - } - } - const records = await RecordModel.find({ - resource: notification.resource, + if (schedule) { + if (cronValidator.isValidCron(schedule)) { + customNotificationMap[notification.id] = new CronJob( + notification.schedule, + async () => { + try { + const resource = await Resource.findOne({ + _id: notification.resource, }); - - 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); - } - } - - 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); - } - } - - 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 userDetail = await User.findById(groupValArr[d]); - if (!!userDetail && !!userDetail.username) { - recipients = [userDetail.username]; - await customNotificationMailSend( - template, - recipients, - notification - ); - } - d++; + if (resource) { + // If triggers check if has filters + const mongooseFilter = getTriggerFilter(notification, resource); + const records = await RecordModel.aggregate([ + { + $match: { + $and: [ + { + resource: notification.resource, + }, + mongooseFilter, + ], + }, + }, + ]); + if (records.length) { + await processCustomNotification( + notification, + application, + resource, + records + ); } } else { - template.content = preprocess(template.content, { - fields: fieldArr, - rows: recordListArr, - }); - await customNotificationMailSend( - template, - recipients, - notification - ); + await processCustomNotification(notification, application); } - } else { - await customNotificationMailSend( - template, - recipients, - notification - ); + } 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); 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 + ); + } + } + } + } + }); } /** 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:` + ); + } +}; 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 }); + } +}; 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) {