diff --git a/.cursor/mcp.json b/.cursor/mcp.json index a7731d6a44..700113020f 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -1,7 +1,3 @@ { - "mcpServers": { - "nx-mcp": { - "url": "http://localhost:9216/sse" - } - } + "mcpServers": {} } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index e388890298..713a1e6be8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,8 @@ "nxConsole.nxWorkspacePath": "", "typescript.preferences.importModuleSpecifier": "non-relative", "typescript.preferences.importModuleSpecifierEnding": "minimal", - "typescript.updateImportsOnFileMove.enabled": "always" + "typescript.updateImportsOnFileMove.enabled": "always", + "[plaintext]": { + "editor.defaultFormatter": "lkrms.inifmt" + } } diff --git a/backend/core-api/src/connectionResolvers.ts b/backend/core-api/src/connectionResolvers.ts index 0573690882..d76bc27500 100644 --- a/backend/core-api/src/connectionResolvers.ts +++ b/backend/core-api/src/connectionResolvers.ts @@ -146,6 +146,8 @@ import { loadClass as loadExecutionClass, } from './modules/automations/db/models/Executions'; import { + AiAgentDocument, + aiAgentSchema, emailDeliverySchema, IAutomationDocument, IAutomationExecutionDocument, @@ -156,6 +158,8 @@ import { notificationConfigSchema, notificationSchema, userNotificationSettingsSchema, + aiEmbeddingSchema, + IAiEmbeddingDocument, } from 'erxes-api-shared/core-modules'; export interface IModels { @@ -196,6 +200,8 @@ export interface IModels { NotificationConfigs: Model; UserNotificationSettings: Model; EmailDeliveries: Model; + AiAgents: Model; + AiEmbeddings: Model; } export interface IContext extends IMainContext { @@ -369,6 +375,20 @@ export const loadClasses = ( IEmailDeliveryDocument, Model >('email_deliveries', emailDeliverySchema); + models.EmailDeliveries = db.model< + IEmailDeliveryDocument, + Model + >('email_deliveries', emailDeliverySchema); + + models.AiAgents = db.model>( + 'automations_ai_agents', + aiAgentSchema, + ); + + models.AiEmbeddings = db.model< + IAiEmbeddingDocument, + Model + >('ai_embeddings', aiEmbeddingSchema); const db_name = db.name; @@ -383,5 +403,5 @@ export const loadClasses = ( }; export const generateModels = createGenerateModels(loadClasses, { - ignoreModels: ['logs', 'automations_executions'], + ignoreModels: ['logs', 'automations_executions', 'ai_embeddings'], }); diff --git a/backend/core-api/src/main.ts b/backend/core-api/src/main.ts index 894bd48ec4..4b4eec1908 100644 --- a/backend/core-api/src/main.ts +++ b/backend/core-api/src/main.ts @@ -15,12 +15,12 @@ import { leaveErxesGateway, } from 'erxes-api-shared/utils'; -import './meta/automations'; +import { initAutomation } from './meta/automations'; import { generateModels } from './connectionResolvers'; import { documents } from './meta/documents'; import { moduleObjects } from './meta/permission'; import { tags } from './meta/tags'; -import './segments'; +import { initSegmentCoreProducers } from './segments'; import * as path from 'path'; import rateLimit from 'express-rate-limit'; @@ -119,6 +119,8 @@ httpServer.listen(port, async () => { documents, }, }); + await initAutomation(app); + await initSegmentCoreProducers(app); }); // GRACEFULL SHUTDOWN diff --git a/backend/core-api/src/meta/automations.ts b/backend/core-api/src/meta/automations.ts index d943978907..58f96a2253 100644 --- a/backend/core-api/src/meta/automations.ts +++ b/backend/core-api/src/meta/automations.ts @@ -5,6 +5,7 @@ import { } from 'erxes-api-shared/core-modules'; import { generateModels, IModels } from '../connectionResolvers'; import { sendTRPCMessage } from 'erxes-api-shared/utils'; +import { Express } from 'express'; const getRelatedValue = async ( models: IModels, @@ -123,126 +124,127 @@ const getItems = async ( return filter ? model.find(filter) : []; }; -export default startAutomations('core', { - receiveActions: async ( - { subdomain }, - { action, execution, triggerType, actionType }, - ) => { - const models = await generateModels(subdomain); - - if (actionType === 'set-property') { - const { module, rules } = action.config; - - const relatedItems = await getItems( - subdomain, - module, - execution, - triggerType, - ); - - const result = await setProperty({ +export const initAutomation = (app: Express) => + startAutomations(app, 'core', { + receiveActions: async ( + { subdomain }, + { action, execution, triggerType, actionType }, + ) => { + const models = await generateModels(subdomain); + + if (actionType === 'set-property') { + const { module, rules } = action.config; + + const relatedItems = await getItems( + subdomain, + module, + execution, + triggerType, + ); + + const result = await setProperty({ + models, + subdomain, + getRelatedValue, + module: module.includes('lead') ? 'core:customer' : module, + rules, + execution, + relatedItems, + triggerType, + }); + return { result }; + } + return { result: 'Hello World Core' }; + }, + replacePlaceHolders: async ({ subdomain }, { data }) => { + const { target, config, relatedValueProps } = data || {}; + const models = await generateModels(subdomain); + + return await replacePlaceHolders({ models, subdomain, - getRelatedValue, - module: module.includes('lead') ? 'core:customer' : module, - rules, - execution, - relatedItems, - triggerType, + target, + actionData: config, + customResolver: { + resolver: getRelatedValue, + props: relatedValueProps, + }, }); - return { result }; - } - return { result: 'Hello World Core' }; - }, - replacePlaceHolders: async ({ subdomain }, { data }) => { - const { target, config, relatedValueProps } = data || {}; - const models = await generateModels(subdomain); - - return await replacePlaceHolders({ - models, - subdomain, - target, - actionData: config, - customResolver: { - resolver: getRelatedValue, - props: relatedValueProps, - }, - }); - }, - getRecipientsEmails: async ({ subdomain }, { data }) => { - const models = await generateModels(subdomain); - const { type, config } = data; + }, + getRecipientsEmails: async ({ subdomain }, { data }) => { + const models = await generateModels(subdomain); + const { type, config } = data; - const ids = config[`${type}Ids`]; + const ids = config[`${type}Ids`]; - const commonFilter = { - _id: { $in: Array.isArray(ids) ? ids : [ids] }, - }; + const commonFilter = { + _id: { $in: Array.isArray(ids) ? ids : [ids] }, + }; - if (type === 'user') { - const result = await models.Users.find(commonFilter).distinct('email'); + if (type === 'user') { + const result = await models.Users.find(commonFilter).distinct('email'); - return result; - } + return result; + } - const CONTACT_TYPES = { - lead: { - model: models.Customers, - filter: { ...commonFilter }, - }, - customer: { - model: models.Customers, - filter: { - ...commonFilter, + const CONTACT_TYPES = { + lead: { + model: models.Customers, + filter: { ...commonFilter }, }, - }, - company: { - model: models.Companies, - filter: { ...commonFilter }, - }, - }; - - const { model, filter } = CONTACT_TYPES[type]; - - return await model.find(filter).distinct('primaryEmail'); - }, - constants: { - triggers: [ - { - type: 'core:user', - icon: 'Users', - label: 'Team member', - description: - 'Start with a blank workflow that enrolls and is triggered off team members', - }, - { - type: 'core:customer', - icon: 'UsersGroup', - label: 'Customer', - description: - 'Start with a blank workflow that enrolls and is triggered off Customers', - }, - { - type: 'core:lead', - icon: 'UsersGroup', - label: 'Lead', - description: - 'Start with a blank workflow that enrolls and is triggered off Leads', - }, - { - type: 'core:company', - icon: 'Building', - label: 'Company', - description: - 'Start with a blank workflow that enrolls and is triggered off company', - }, - { - type: 'core:form_submission', - icon: 'Forms', - label: 'Form submission', - description: - 'Start with a blank workflow that enrolls and is triggered off form submission', - }, - ], - }, -}); + customer: { + model: models.Customers, + filter: { + ...commonFilter, + }, + }, + company: { + model: models.Companies, + filter: { ...commonFilter }, + }, + }; + + const { model, filter } = CONTACT_TYPES[type]; + + return await model.find(filter).distinct('primaryEmail'); + }, + constants: { + triggers: [ + { + type: 'core:user', + icon: 'Users', + label: 'Team member', + description: + 'Start with a blank workflow that enrolls and is triggered off team members', + }, + { + type: 'core:customer', + icon: 'UsersGroup', + label: 'Customer', + description: + 'Start with a blank workflow that enrolls and is triggered off Customers', + }, + { + type: 'core:lead', + icon: 'UsersGroup', + label: 'Lead', + description: + 'Start with a blank workflow that enrolls and is triggered off Leads', + }, + { + type: 'core:company', + icon: 'Building', + label: 'Company', + description: + 'Start with a blank workflow that enrolls and is triggered off company', + }, + { + type: 'core:form_submission', + icon: 'Forms', + label: 'Form submission', + description: + 'Start with a blank workflow that enrolls and is triggered off form submission', + }, + ], + }, + }); diff --git a/backend/core-api/src/modules/auth/graphql/resolvers/mutations.ts b/backend/core-api/src/modules/auth/graphql/resolvers/mutations.ts index 712a29be49..d64c07f2b6 100644 --- a/backend/core-api/src/modules/auth/graphql/resolvers/mutations.ts +++ b/backend/core-api/src/modules/auth/graphql/resolvers/mutations.ts @@ -149,6 +149,8 @@ export const authMutations = { assertSaasEnvironment(); const WORKOS_API_KEY = getEnv({ name: 'WORKOS_API_KEY', subdomain }); + const CORE_DOMAIN = getEnv({ name: 'CORE_DOMAIN', subdomain }); + const workosClient = new WorkOS(WORKOS_API_KEY); const state = await jwt.sign( @@ -162,7 +164,7 @@ export const authMutations = { const authorizationURL = workosClient.sso.getAuthorizationUrl({ provider: 'GoogleOAuth', - redirectUri: getCallbackRedirectUrl(subdomain, 'sso-callback'), + redirectUri: `${CORE_DOMAIN}/saas-sso-callback`, clientId: getEnv({ name: 'WORKOS_PROJECT_ID', subdomain }), state, diff --git a/backend/core-api/src/modules/automations/constants.ts b/backend/core-api/src/modules/automations/constants.ts deleted file mode 100644 index 9cee231c68..0000000000 --- a/backend/core-api/src/modules/automations/constants.ts +++ /dev/null @@ -1,124 +0,0 @@ -export const ACTIONS = { - WAIT: 'delay', - IF: 'if', - SET_PROPERTY: 'setProperty', - SEND_EMAIL: 'sendEmail', -}; - -export const EMAIL_RECIPIENTS_TYPES = [ - { - type: 'customMail', - name: 'customMails', - label: 'Custom Mails', - }, - { - type: 'attributionMail', - name: 'attributionMails', - label: 'Attribution Mails', - }, - { - type: 'segmentBased', - name: 'segmentBased', - label: 'Trigger Segment Based Mails', - }, - { - type: 'teamMember', - name: 'teamMemberIds', - label: 'Team Members', - }, - { - type: 'lead', - name: 'leadIds', - label: 'Leads', - }, - { - type: 'customer', - name: 'customerIds', - label: 'Customers', - }, - { - type: 'company', - name: 'companyIds', - label: 'Companies', - }, -]; - -export const UI_ACTIONS = [ - { - type: 'if', - icon: 'IconSitemap', - label: 'Branches', - description: 'Create simple or if/then branches', - isAvailable: true, - }, - { - type: 'setProperty', - icon: 'IconFlask', - label: 'Manage properties', - description: - 'Update existing default or custom properties for Contacts, Companies, Cards, Conversations', - isAvailable: true, - }, - { - type: 'delay', - icon: 'IconHourglass', - label: 'Delay', - description: - 'Delay the next action with a timeframe, a specific event or activity', - isAvailable: true, - }, - { - type: 'workflow', - icon: 'IconJumpRope', - label: 'Workflow', - description: - 'Enroll in another workflow, trigger outgoing webhook or write custom code', - isAvailable: false, - }, - { - type: 'sendEmail', - icon: 'IconMailFast', - label: 'Send Email', - description: 'Send Email', - emailRecipientsConst: EMAIL_RECIPIENTS_TYPES, - isAvailable: true, - }, -]; - -export const UI_TRIGGERS = [ - { - type: 'core:user', - icon: 'IconUsers', - label: 'Team member', - description: - 'Start with a blank workflow that enrolls and is triggered off team members', - }, - { - type: 'core:customer', - icon: 'IconUsersGroup', - label: 'Customer', - description: - 'Start with a blank workflow that enrolls and is triggered off Customers', - }, - { - type: 'core:lead', - icon: 'IconUsersGroup', - label: 'Lead', - description: - 'Start with a blank workflow that enrolls and is triggered off Leads', - }, - { - type: 'core:company', - icon: 'IconBuilding', - label: 'Company', - description: - 'Start with a blank workflow that enrolls and is triggered off company', - }, - { - type: 'core:form_submission', - icon: 'IconForms', - label: 'Form submission', - description: - 'Start with a blank workflow that enrolls and is triggered off form submission', - }, -]; diff --git a/backend/core-api/src/modules/automations/graphql/resolvers/mutations.ts b/backend/core-api/src/modules/automations/graphql/resolvers/mutations.ts index 560c714eaf..f34a96ba80 100644 --- a/backend/core-api/src/modules/automations/graphql/resolvers/mutations.ts +++ b/backend/core-api/src/modules/automations/graphql/resolvers/mutations.ts @@ -1,8 +1,10 @@ import { + AUTOMATION_STATUSES, + checkPermission, IAutomation, IAutomationDoc, - AUTOMATION_STATUSES, } from 'erxes-api-shared/core-modules'; +import { sendWorkerMessage } from 'erxes-api-shared/utils'; import { IContext } from '~/connectionResolvers'; export interface IAutomationsEdit extends IAutomation { @@ -13,11 +15,7 @@ export const automationMutations = { /** * Creates a new automation */ - async automationsAdd( - _root, - doc: IAutomation, - { user, models, subdomain }: IContext, - ) { + async automationsAdd(_root, doc: IAutomation, { user, models }: IContext) { const automation = await models.Automations.create({ ...doc, createdAt: new Date(), @@ -34,11 +32,14 @@ export const automationMutations = { async automationsEdit( _root, { _id, ...doc }: IAutomationsEdit, - { user, models, subdomain }: IContext, + { user, models }: IContext, ) { const automation = await models.Automations.getAutomation(_id); + if (!automation) { + throw new Error('Automation not found'); + } - const updated = await models.Automations.updateOne( + await models.Automations.updateOne( { _id }, { $set: { ...doc, updatedAt: new Date(), updatedBy: user._id } }, ); @@ -67,79 +68,6 @@ export const automationMutations = { ); return automationIds; }, - - /** - * Save as a template - */ - async automationsSaveAsTemplate( - _root, - { - _id, - name, - duplicate, - }: { _id: string; name?: string; duplicate?: boolean }, - { user, models }: IContext, - ) { - const automation = await models.Automations.getAutomation(_id); - - const automationDoc: IAutomationDoc = { - ...automation, - createdAt: new Date(), - createdBy: user._id, - updatedBy: user._id, - }; - - if (name) { - automationDoc.name = name; - } - - if (duplicate) { - automationDoc.name = `${automationDoc.name} duplicated`; - } else { - automationDoc.status = 'template'; - } - - delete automationDoc._id; - - const created = await models.Automations.create({ - ...automationDoc, - }); - - return await models.Automations.getAutomation(created._id); - }, - - /** - * Save as a template - */ - async automationsCreateFromTemplate( - _root, - { _id }: { _id: string }, - { user, models, subdomain }: IContext, - ) { - const automation = await models.Automations.getAutomation(_id); - - if (automation.status !== 'template') { - throw new Error('Not template'); - } - - const automationDoc: IAutomationDoc = { - ...automation, - status: 'template', - name: (automation.name += ' from template'), - createdAt: new Date(), - createdBy: user._id, - updatedBy: user._id, - }; - - delete automationDoc._id; - - const created = await models.Automations.create({ - ...automationDoc, - }); - - return await models.Automations.getAutomation(created._id); - }, - /** * Removes automations */ @@ -171,18 +99,50 @@ export const automationMutations = { await models.Automations.deleteMany({ _id: { $in: automationIds } }); await models.AutomationExecutions.removeExecutions(automationIds); - for (const segmentId of segmentIds || []) { - // sendSegmentsMessage({ - // subdomain: '', - // action: 'removeSegment', - // data: { segmentId } - // }); - } + await models.Segments.deleteMany({ _id: { $in: segmentIds } }); return automationIds; }, + + async automationsAiAgentAdd(_root, doc, { models }: IContext) { + return await models.AiAgents.create(doc); + }, + async automationsAiAgentEdit(_root, { _id, ...doc }, { models }: IContext) { + return await models.AiAgents.updateOne({ _id }, { $set: { ...doc } }); + }, + + async startAiTraining(_root, { agentId }, { subdomain }: IContext) { + await sendWorkerMessage({ + pluginName: 'automations', + queueName: 'aiAgent', + jobName: 'trainAiAgent', + subdomain, + data: { agentId }, + }); + return await sendWorkerMessage({ + pluginName: 'automations', + queueName: 'aiAgent', + jobName: 'trainAiAgent', + subdomain, + data: { agentId }, + }); + }, + + async generateAgentMessage( + _root, + { agentId, question }, + { subdomain }: IContext, + ) { + return await sendWorkerMessage({ + pluginName: 'automations', + queueName: 'aiAgent', + jobName: 'generateText', + subdomain, + data: { agentId, question }, + }); + }, }; -// checkPermission(automationMutations, 'automationsAdd', 'automationsAdd'); -// checkPermission(automationMutations, 'automationsEdit', 'automationsEdit'); -// checkPermission(automationMutations, 'automationsRemove', 'automationsRemove'); +checkPermission(automationMutations, 'automationsAdd', 'automationsAdd'); +checkPermission(automationMutations, 'automationsEdit', 'automationsEdit'); +checkPermission(automationMutations, 'automationsRemove', 'automationsRemove'); diff --git a/backend/core-api/src/modules/automations/graphql/resolvers/queries.ts b/backend/core-api/src/modules/automations/graphql/resolvers/queries.ts index 9ff8df8db7..4673595d15 100644 --- a/backend/core-api/src/modules/automations/graphql/resolvers/queries.ts +++ b/backend/core-api/src/modules/automations/graphql/resolvers/queries.ts @@ -1,16 +1,25 @@ -import { cursorPaginate, getPlugin, getPlugins } from 'erxes-api-shared/utils'; +import { + cursorPaginate, + getEnv, + getPlugin, + getPlugins, +} from 'erxes-api-shared/utils'; import { + AUTOMATION_ACTIONS, + AUTOMATION_CORE_PROPERTY_TYPES, AUTOMATION_STATUSES, + AUTOMATION_TRIGGERS, + checkPermission, IAutomationDocument, IAutomationExecutionDocument, IAutomationsActionConfig, IAutomationsTriggerConfig, + requireLogin, } from 'erxes-api-shared/core-modules'; import { ICursorPaginateParams } from 'erxes-api-shared/core-types'; import { IContext } from '~/connectionResolvers'; -import { UI_ACTIONS, UI_TRIGGERS } from '../../constants'; export interface IListArgs extends ICursorPaginateParams { status: string; @@ -21,6 +30,7 @@ export interface IListArgs extends ICursorPaginateParams { sortField: string; sortDirection: number; tagIds: string[]; + excludeIds: string[]; triggerTypes: string[]; } @@ -36,7 +46,14 @@ export interface IHistoriesParams { } const generateFilter = (params: IListArgs) => { - const { status, searchValue, tagIds, triggerTypes, ids } = params; + const { + status, + searchValue, + tagIds, + triggerTypes, + ids, + excludeIds = [], + } = params; const filter: any = { status: { $nin: [AUTOMATION_STATUSES.ARCHIVED, 'template'] }, @@ -61,6 +78,9 @@ const generateFilter = (params: IListArgs) => { if (ids?.length) { filter._id = { $in: ids }; } + if (excludeIds.length) { + filter._id = { $nin: excludeIds }; + } return filter; }; @@ -192,46 +212,6 @@ export const automationQueries = { return await models.AutomationExecutions.find(filter).countDocuments(); }, - async automationConfigPrievewCount( - _root, - params: { config: any }, - { subdomain }: IContext, - ) { - return; - // const config = params.config; - // if (!config) { - // return; - // } - - // const contentId = config.contentId; - // if (!contentId) { - // return; - // } - - // const segment = await sendSegmentsMessage({ - // subdomain, - // action: 'findOne', - // data: { _id: contentId }, - // isRPC: true - // }); - - // if (!segment) { - // return; - // } - - // const result = await sendSegmentsMessage({ - // subdomain, - // action: 'fetchSegment', - // data: { - // segmentId: segment._id, - // options: { returnCount: true } - // }, - // isRPC: true - // }); - - // return result; - }, - async automationsTotalCount( _root, { status }: { status: string }, @@ -255,12 +235,24 @@ export const automationQueries = { actionsConst: IAutomationsActionConfig[]; propertyTypesConst: Array<{ value: string; label: string }>; } = { - triggersConst: [...UI_TRIGGERS], + triggersConst: [...AUTOMATION_TRIGGERS], triggerTypesConst: [], - actionsConst: [...UI_ACTIONS], - propertyTypesConst: [], + actionsConst: [...AUTOMATION_ACTIONS], + propertyTypesConst: [...AUTOMATION_CORE_PROPERTY_TYPES], }; + // Track seen items to avoid duplicates + const seenTriggerTypes = new Set( + constants.triggersConst.map((t) => t.type), + ); + const seenTriggerTypeStrings = new Set(); + const seenPropertyValues = new Set( + constants.propertyTypesConst.map((p) => p.value), + ); + const seenActionTypes = new Set( + constants.actionsConst.map((a) => a.type), + ); + for (const pluginName of plugins) { const plugin = await getPlugin(pluginName); const meta = plugin.config?.meta || {}; @@ -270,16 +262,30 @@ export const automationQueries = { const { triggers = [], actions = [] } = pluginConstants; for (const trigger of triggers) { - constants.triggersConst.push({ ...trigger, pluginName }); - constants.triggerTypesConst.push(trigger.type); - constants.propertyTypesConst.push({ - value: trigger.type, - label: trigger.label, - }); + if (!seenTriggerTypes.has(trigger.type)) { + constants.triggersConst.push({ ...trigger, pluginName }); + seenTriggerTypes.add(trigger.type); + } + + if (!seenTriggerTypeStrings.has(trigger.type)) { + constants.triggerTypesConst.push(trigger.type); + seenTriggerTypeStrings.add(trigger.type); + } + + if (!seenPropertyValues.has(trigger.type)) { + constants.propertyTypesConst.push({ + value: trigger.type, + label: trigger.label, + }); + seenPropertyValues.add(trigger.type); + } } for (const action of actions) { - constants.actionsConst.push({ ...action, pluginName }); + if (!seenActionTypes.has(action.type)) { + constants.actionsConst.push({ ...action, pluginName }); + seenActionTypes.add(action.type); + } } if (pluginConstants?.emailRecipientTypes?.length) { @@ -288,16 +294,32 @@ export const automationQueries = { ...eRT, pluginName, })); - constants.actionsConst = constants.actionsConst.map((actionConst) => - actionConst.type === 'sendEmail' - ? { - ...actionConst, - emailRecipientsConst: actionConst.emailRecipientsConst.concat( - updatedEmailRecipIentTypes, - ), - } - : actionConst, - ); + constants.actionsConst = constants.actionsConst.map((actionConst) => { + if (actionConst.type !== 'sendEmail') { + return actionConst; + } + + const baseRecipients = actionConst.emailRecipientsConst || []; + const merged = [...baseRecipients, ...updatedEmailRecipIentTypes]; + + const seenRecipientValues = new Set(); + const dedupedRecipients = merged.filter((recipient: any) => { + const key = recipient.value ?? recipient.type ?? recipient.label; + if (!key) { + return true; + } + if (seenRecipientValues.has(key)) { + return false; + } + seenRecipientValues.add(key); + return true; + }); + + return { + ...actionConst, + emailRecipientsConst: dedupedRecipients, + } as IAutomationsActionConfig as any; + }); } } } @@ -305,6 +327,22 @@ export const automationQueries = { return constants; }, + async getAutomationWebhookEndpoint( + _root, + { _id }, + { models, subdomain }: IContext, + ) { + const DOMAIN = getEnv({ name: 'DOMAIN', subdomain }); + + const automation = await models.Automations.findById(_id).lean(); + + if (!automation) { + throw new Error('Not found'); + } + + return `${DOMAIN}/${automation._id}/`; + }, + async automationBotsConstants() { const plugins = await getPlugins(); const botsConstants: any[] = []; @@ -320,14 +358,41 @@ export const automationQueries = { return botsConstants; }, + + async automationsAiAgents(_root, { kind }, { models }: IContext) { + return await models.AiAgents.find(kind ? { provider: kind } : {}); + }, + + async automationsAiAgentDetail(_root, _, { models }: IContext) { + return await models.AiAgents.findOne({}); + }, + + async getTrainingStatus(_root, { agentId }, {}: IContext) { + const agent = await this.models.AiAgents.findById(agentId); + if (!agent) { + throw new Error('AI Agent not found'); + } + + const files = agent.files || []; + const embeddedFiles = await this.models.AiEmbeddings.find({ + fileId: { $in: files.map(({ id }) => id) }, + }); + + return { + agentId, + totalFiles: files.length, + processedFiles: embeddedFiles.length, + status: embeddedFiles.length === files.length ? 'completed' : 'pending', + }; + }, }; -// requireLogin(automationQueries, 'automationsMain'); -// requireLogin(automationQueries, 'automationNotes'); -// requireLogin(automationQueries, 'automationDetail'); +requireLogin(automationQueries, 'automationsMain'); +requireLogin(automationQueries, 'automationNotes'); +requireLogin(automationQueries, 'automationDetail'); -// checkPermission(automationQueries, 'automations', 'showAutomations', []); -// checkPermission(automationQueries, 'automationsMain', 'showAutomations', { -// list: [], -// totalCount: 0 -// }); +checkPermission(automationQueries, 'automations', 'showAutomations', []); +checkPermission(automationQueries, 'automationsMain', 'showAutomations', { + list: [], + totalCount: 0, +}); diff --git a/backend/core-api/src/modules/automations/graphql/schema/mutations.ts b/backend/core-api/src/modules/automations/graphql/schema/mutations.ts index 93a566ea00..c374ade11b 100644 --- a/backend/core-api/src/modules/automations/graphql/schema/mutations.ts +++ b/backend/core-api/src/modules/automations/graphql/schema/mutations.ts @@ -3,6 +3,8 @@ const commonFields = ` status: String triggers: [TriggerInput], actions: [ActionInput], + workflows: [WorkflowInput] + `; const commonNoteFields = ` @@ -12,6 +14,16 @@ const commonNoteFields = ` description: String `; +const aiAgentParams = ` + name:String, + description:String, + provider:String, + prompt:String, + instructions:String, + files:JSON, + config:JSON, +`; + const mutations = ` automationsAdd(${commonFields}): Automation automationsEdit(_id: String, ${commonFields}): Automation @@ -24,6 +36,11 @@ const mutations = ` automationsAddNote(${commonNoteFields}): AutomationNote automationsEditNote(_id: String!, ${commonNoteFields}): AutomationNote automationsRemoveNote(_id: String!): AutomationNote + automationsAiAgentAdd(${aiAgentParams}):JSON + automationsAiAgentEdit(_id:String!,${aiAgentParams}):JSON + startAiTraining(agentId: String!): TrainingProgress! + getTrainingStatus(agentId: String!): TrainingProgress! + generateAgentMessage(agentId: String!, prevQuestions:[String],question: String!): AiAgentMessage! `; export default mutations; diff --git a/backend/core-api/src/modules/automations/graphql/schema/queries.ts b/backend/core-api/src/modules/automations/graphql/schema/queries.ts index 56998f2c0a..46c8a4230f 100644 --- a/backend/core-api/src/modules/automations/graphql/schema/queries.ts +++ b/backend/core-api/src/modules/automations/graphql/schema/queries.ts @@ -4,7 +4,7 @@ const queryParams = ` page: Int perPage: Int ids: [String] - excludeIds: Boolean + excludeIds: [String] searchValue: String sortField: String sortDirection: Int @@ -34,10 +34,13 @@ const queries = ` automationNotes(automationId: String!, triggerId: String, actionId: String): [AutomationNote] automationHistories(${GQL_CURSOR_PARAM_DEFS},${historiesParams}): AutomationHistories automationHistoriesTotalCount(${historiesParams}):Int - automationConfigPrievewCount(config: JSON): Int automationsTotalCount(status: String): automationsTotalCountResponse automationConstants: JSON automationBotsConstants:JSON + automationsAiAgents(kind:String):JSON + automationsAiAgentDetail:JSON + getTrainingStatus(agentId: String!): TrainingProgress! + getAutomationWebhookEndpoint(_id:String!):String `; export default queries; diff --git a/backend/core-api/src/modules/automations/graphql/schema/types.ts b/backend/core-api/src/modules/automations/graphql/schema/types.ts index 15c94e42a6..dfb2b583c3 100644 --- a/backend/core-api/src/modules/automations/graphql/schema/types.ts +++ b/backend/core-api/src/modules/automations/graphql/schema/types.ts @@ -21,6 +21,15 @@ const commonActionTypes = ` nextActionId: String `; +const workflowTypes = ` + id:String + automationId:String + name:String + description:String + config:JSON + position:JSON +`; + const types = ` type Trigger { ${commonTriggerTypes} @@ -32,6 +41,10 @@ const types = ` ${commonActionTypes} } + type Workflow { + ${workflowTypes} + } + type Automation { _id: String! name: String @@ -43,6 +56,7 @@ const types = ` tagIds:[String] triggers: [Trigger] actions: [Action] + workflows: [Workflow] createdUser: User updatedUser: User @@ -100,6 +114,24 @@ const types = ` input ActionInput { ${commonActionTypes} } + + input WorkflowInput { + ${workflowTypes} + } + + type TrainingProgress { + agentId: String! + totalFiles: Int! + processedFiles: Int! + status: String! + error: String + } + + type AiAgentMessage { + message: String! + relevantFile: String + similarity: Float + } `; export default types; diff --git a/backend/core-api/src/modules/automations/trpc/automations.ts b/backend/core-api/src/modules/automations/trpc/automations.ts index c4a818fa93..4cbe166883 100644 --- a/backend/core-api/src/modules/automations/trpc/automations.ts +++ b/backend/core-api/src/modules/automations/trpc/automations.ts @@ -1,4 +1,5 @@ import { initTRPC } from '@trpc/server'; +import { getEnv } from 'erxes-api-shared/utils'; import { z } from 'zod'; import { CoreTRPCContext } from '~/init-trpc'; diff --git a/backend/core-api/src/modules/permissions/db/models/Permissions.ts b/backend/core-api/src/modules/permissions/db/models/Permissions.ts index 0084daf9ee..161629cf8f 100644 --- a/backend/core-api/src/modules/permissions/db/models/Permissions.ts +++ b/backend/core-api/src/modules/permissions/db/models/Permissions.ts @@ -1,5 +1,5 @@ import { - IActionsMap, + IAutomationActionsMap, IPermission, IPermissionDocument, IPermissionParams, @@ -27,7 +27,7 @@ export const loadPermissionClass = (models: IModels) => { let filter = {}; - let actionObj: IActionsMap; + let actionObj: IAutomationActionsMap; const actionsMap = await getPermissionActionsMap(); diff --git a/backend/core-api/src/modules/permissions/utils.ts b/backend/core-api/src/modules/permissions/utils.ts index 0d48ae6502..f8622c6db4 100644 --- a/backend/core-api/src/modules/permissions/utils.ts +++ b/backend/core-api/src/modules/permissions/utils.ts @@ -1,6 +1,6 @@ import { getKey } from 'erxes-api-shared/core-modules'; import { - IActionsMap, + IAutomationActionsMap, IModuleMap, IPermissionDocument, } from 'erxes-api-shared/core-types'; @@ -51,7 +51,7 @@ export const getPermissionModules = async () => { }; export const getPermissionActions = async () => { - const actions: IActionsMap[] = []; + const actions: IAutomationActionsMap[] = []; const services = await getPlugins(); @@ -85,45 +85,46 @@ export const getPermissionActions = async () => { return actions; }; -export const getPermissionActionsMap = async (): Promise => { - const actionsMap: IActionsMap = {}; +export const getPermissionActionsMap = + async (): Promise => { + const actionsMap: IAutomationActionsMap = {}; - const services = await getPlugins(); + const services = await getPlugins(); - for (const name of services) { - const service = await getPlugin(name); - if (!service) continue; - if (!service.config) continue; + for (const name of services) { + const service = await getPlugin(name); + if (!service) continue; + if (!service.config) continue; - const permissions = - service.config.meta?.permissions || service.config.permissions; + const permissions = + service.config.meta?.permissions || service.config.permissions; - if (!permissions) continue; + if (!permissions) continue; - const moduleKeys = Object.keys(permissions); + const moduleKeys = Object.keys(permissions); - for (const key of moduleKeys) { - const module = permissions[key]; + for (const key of moduleKeys) { + const module = permissions[key]; - if (module.actions) { - for (const action of module.actions) { - if (!action.name) continue; + if (module.actions) { + for (const action of module.actions) { + if (!action.name) continue; - actionsMap[action.name] = { - module: module.name, - description: action.description, - }; + actionsMap[action.name] = { + module: module.name, + description: action.description, + }; - if (action.use) { - actionsMap[action.name].use = action.use; + if (action.use) { + actionsMap[action.name].use = action.use; + } } } } } - } - return actionsMap; -}; + return actionsMap; + }; export const fixPermissions = async ( models: IModels, @@ -137,9 +138,8 @@ export const fixPermissions = async ( const moduleItem: IModuleMap = permissionObjects[mod]; if (moduleItem && moduleItem.actions) { - const allAction: IActionsMap | undefined = moduleItem.actions.find( - (a) => a.description === 'All', - ); + const allAction: IAutomationActionsMap | undefined = + moduleItem.actions.find((a) => a.description === 'All'); const otherActions = moduleItem.actions .filter((a) => a.description !== 'All') .map((a) => a.name); diff --git a/backend/core-api/src/modules/segments/trpc/segments.ts b/backend/core-api/src/modules/segments/trpc/segments.ts index cf79245ebe..68543186cc 100644 --- a/backend/core-api/src/modules/segments/trpc/segments.ts +++ b/backend/core-api/src/modules/segments/trpc/segments.ts @@ -83,5 +83,9 @@ export const segmentsRouter = t.router({ return await fetchSegment(models, subdomain, segment, options); }), + findOne: t.procedure.query(async ({ input, ctx }) => { + const { models } = ctx; + return await models.Segments.findOne(input); + }), }), }); diff --git a/backend/core-api/src/modules/segments/utils/common.ts b/backend/core-api/src/modules/segments/utils/common.ts index b31738ecff..25c15649a3 100644 --- a/backend/core-api/src/modules/segments/utils/common.ts +++ b/backend/core-api/src/modules/segments/utils/common.ts @@ -1,14 +1,14 @@ +import { splitType, TSegmentProducers } from 'erxes-api-shared/core-modules'; import { fetchByQuery, generateElkIds, getPlugin, - sendWorkerMessage, + sendCoreModuleProducer, } from 'erxes-api-shared/utils'; import { IModels } from '~/connectionResolvers'; import { SEGMENT_DATE_OPERATORS, SEGMENT_NUMBER_OPERATORS } from '../constants'; import { ICondition, ISegment } from '../db/definitions/segments'; import { IOptions } from '../types'; -import { splitType } from 'erxes-api-shared/core-modules'; const generateDefaultSelector = ({ defaultMustSelector, isInitialCall }) => { if (isInitialCall && defaultMustSelector) { @@ -113,12 +113,11 @@ export const generateQueryBySegment = async ( continue; } if (esTypesMapAvailable) { - const response = await sendWorkerMessage({ - subdomain, + const response = await sendCoreModuleProducer({ + module: 'segments', pluginName, - queueName: 'segments', - jobName: 'esTypesMap', - data: { + producerName: TSegmentProducers.ES_TYPES_MAP, + input: { collectionType, }, }); @@ -127,12 +126,11 @@ export const generateQueryBySegment = async ( } if (initialSelectorAvailable) { - const { negative, positive } = await sendWorkerMessage({ - subdomain, + const { negative, positive } = await sendCoreModuleProducer({ + module: 'segments', pluginName, - queueName: 'segments', - jobName: 'initialSelector', - data: { + producerName: TSegmentProducers.INITIAL_SELECTOR, + input: { segment, options, }, @@ -231,12 +229,12 @@ export const generateQueryBySegment = async ( continue; } const { positive, ignoreThisPostiveQuery } = - await sendWorkerMessage({ - subdomain, + await sendCoreModuleProducer({ + module: 'segments', pluginName: propertyPluginName, - queueName: 'segments', - jobName: 'propertyConditionExtender', - data: { condition, positiveQuery }, + producerName: TSegmentProducers.PROPERTY_CONDITION_EXTENDER, + input: { condition, positiveQuery }, + defaultValue: { positive: null, ignoreThisPostiveQuery: false }, }); if (positive) { @@ -590,12 +588,11 @@ const associationPropertyFilter = async ( const segmentMeta = (plugin.config.meta || {}).segments; if (segmentMeta && segmentMeta.associationFilterAvailable) { - return await sendWorkerMessage({ - subdomain, + return await sendCoreModuleProducer({ + module: 'segments', pluginName, - queueName: 'segments', - jobName: 'associationFilter', - data: { + producerName: TSegmentProducers.ASSOCIATION_FILTER, + input: { mainType, propertyType, positiveQuery, diff --git a/backend/core-api/src/segments.ts b/backend/core-api/src/segments.ts index 199df0910b..61d0f0216f 100644 --- a/backend/core-api/src/segments.ts +++ b/backend/core-api/src/segments.ts @@ -3,7 +3,7 @@ import { getContentType, getEsIndexByContentType, getPluginName, - startSegments, + initSegmentProducers, } from 'erxes-api-shared/core-modules'; import { generateModels } from './connectionResolvers'; import { @@ -13,117 +13,119 @@ import { sendTRPCMessage, } from 'erxes-api-shared/utils'; import _ from 'underscore'; +import { Express } from 'express'; const changeType = (type: string) => type === 'core:lead' ? 'core:customer' : type; -export default startSegments('core', { - contentTypes: [ - { - type: 'user', - description: 'Team member', - esIndex: 'users', - }, - { - type: 'form_submission', - description: 'Form submission', - esIndex: 'form_submissions', - hideInSidebar: true, +export const initSegmentCoreProducers = (app: Express) => + initSegmentProducers(app, 'core', { + contentTypes: [ + { + type: 'user', + description: 'Team member', + esIndex: 'users', + }, + { + type: 'form_submission', + description: 'Form submission', + esIndex: 'form_submissions', + hideInSidebar: true, + }, + { type: 'company', description: 'Company', esIndex: 'companies' }, + { type: 'customer', description: 'Customer', esIndex: 'customers' }, + { + type: 'lead', + description: 'Lead', + esIndex: 'customers', + notAssociated: true, + }, + { type: 'product', description: 'Product', esIndex: 'products' }, + ], + esTypesMap: async () => { + return { typesMap: {} }; }, - { type: 'company', description: 'Company', esIndex: 'companies' }, - { type: 'customer', description: 'Customer', esIndex: 'customers' }, - { - type: 'lead', - description: 'Lead', - esIndex: 'customers', - notAssociated: true, + + initialSelector: async () => { + const negative = { + term: { + status: 'deleted', + }, + }; + + return { negative }; }, - { type: 'product', description: 'Product', esIndex: 'products' }, - ], - esTypesMap: async () => { - return { typesMap: {} }; - }, - initialSelector: async () => { - const negative = { - term: { - status: 'deleted', - }, - }; + associationFilter: async ( + { subdomain }, + { mainType, propertyType, positiveQuery, negativeQuery }, + ) => { + const associatedTypes: string[] = await gatherAssociatedTypes( + changeType(mainType), + ); - return { negative }; - }, + let ids: string[] = []; - associationFilter: async ( - { subdomain }, - { mainType, propertyType, positiveQuery, negativeQuery }, - ) => { - const associatedTypes: string[] = await gatherAssociatedTypes( - changeType(mainType), - ); + if ( + associatedTypes + .filter((type) => type !== 'core:form_submission') + .includes(propertyType) || + propertyType === 'core:lead' + ) { + const models = await generateModels(subdomain); - let ids: string[] = []; + const mainTypeIds = ( + await fetchByQueryWithScroll({ + subdomain, + index: await getEsIndexByContentType(propertyType), + positiveQuery, + negativeQuery, + }) + ).map((id) => getRealIdFromElk(id)); - if ( - associatedTypes - .filter((type) => type !== 'core:form_submission') - .includes(propertyType) || - propertyType === 'core:lead' - ) { - const models = await generateModels(subdomain); + return await models.Conformities.filterConformity({ + mainType: getContentType(changeType(propertyType)), + mainTypeIds, + relType: getContentType(changeType(mainType)), + }); + } - const mainTypeIds = ( - await fetchByQueryWithScroll({ + if (propertyType === 'core:form_submission') { + ids = await fetchByQuery({ subdomain, - index: await getEsIndexByContentType(propertyType), + index: 'form_submissions', + _source: 'customerId', positiveQuery, negativeQuery, - }) - ).map((id) => getRealIdFromElk(id)); - - return await models.Conformities.filterConformity({ - mainType: getContentType(changeType(propertyType)), - mainTypeIds, - relType: getContentType(changeType(mainType)), - }); - } + }); + } else { + const serviceName = getPluginName(propertyType); - if (propertyType === 'core:form_submission') { - ids = await fetchByQuery({ - subdomain, - index: 'form_submissions', - _source: 'customerId', - positiveQuery, - negativeQuery, - }); - } else { - const serviceName = getPluginName(propertyType); + if (propertyType.includes('customer', 'company')) { + return { data: [], status: 'error' }; + } - if (propertyType.includes('customer', 'company')) { - return { data: [], status: 'error' }; - } + if (serviceName === 'core') { + return { data: [], status: 'error' }; + } - if (serviceName === 'core') { - return { data: [], status: 'error' }; + ids = await sendTRPCMessage({ + pluginName: serviceName, + method: 'query', + module: 'segments', + action: 'associationFilter', + input: { + mainType, + propertyType, + positiveQuery, + negativeQuery, + }, + defaultValue: [], + }); } - ids = await sendTRPCMessage({ - pluginName: serviceName, - method: 'query', - module: 'segments', - action: 'associationFilter', - input: { - mainType, - propertyType, - positiveQuery, - negativeQuery, - }, - defaultValue: [], - }); - } - - ids = _.uniq(ids); + ids = _.uniq(ids); - return ids; - }, -}); + return ids; + }, + }); diff --git a/backend/erxes-api-shared/src/core-modules/automations/constants.ts b/backend/erxes-api-shared/src/core-modules/automations/constants.ts index 75d72ae2f7..47596bacf9 100644 --- a/backend/erxes-api-shared/src/core-modules/automations/constants.ts +++ b/backend/erxes-api-shared/src/core-modules/automations/constants.ts @@ -20,4 +20,214 @@ export const AUTOMATION_STATUSES = { DRAFT: 'draft', ACTIVE: 'active', ARCHIVED: 'archived', +} as const; + +export const AUTOMATION_CORE_ACTIONS = { + DELAY: 'delay', + IF: 'if', + FIND_OBJECT: 'findObject', + SET_PROPERTY: 'setProperty', + SEND_EMAIL: 'sendEmail', + OUTGOING_WEBHOOK: 'outgoingWebhook', + WAIT_EVENT: 'waitEvent', + AI_AGENT: 'aiAgent', +}; + +export const AUTOMATION_CORE_TRIGGER_TYPES = { + INCOMING_WEBHOOK: 'core:incoming_webhook', + USER: 'core:user', + CUSTOMER: 'core:customer', + LEAD: 'core:lead', + COMPANY: 'core:company', + FORM_SUBMISSION: 'core:form_submission', }; + +export const AUTOMATION_EMAIL_RECIPIENTS_TYPES = [ + { + type: 'customMail', + name: 'customMails', + label: 'Custom Mails', + }, + { + type: 'attributionMail', + name: 'attributionMails', + label: 'Attribution Mails', + }, + { + type: 'segmentBased', + name: 'segmentBased', + label: 'Trigger Segment Based Mails', + }, + { + type: 'teamMember', + name: 'teamMemberIds', + label: 'Team Members', + }, + { + type: 'lead', + name: 'leadIds', + label: 'Leads', + }, + { + type: 'customer', + name: 'customerIds', + label: 'Customers', + }, + { + type: 'company', + name: 'companyIds', + label: 'Companies', + }, +]; + +export const AUTOMATION_ACTIONS = [ + { + type: AUTOMATION_CORE_ACTIONS.OUTGOING_WEBHOOK, + icon: 'IconWebhook', + label: 'Outgoing webhook', + description: 'Outgoing webhook', + }, + { + type: AUTOMATION_CORE_ACTIONS.IF, + icon: 'IconSitemap', + label: 'Branches', + description: 'Create simple or if/then branches', + folks: [ + { key: 'yes', label: 'Yes', type: 'success' }, + { key: 'no', label: 'No', type: 'error' }, + ], + }, + { + type: AUTOMATION_CORE_ACTIONS.FIND_OBJECT, + icon: 'IconSearch', + label: 'Find object', + description: 'Find object', + folks: [ + { key: 'isExists', label: 'Has', type: 'success' }, + { key: 'notExists', label: 'None', type: 'error' }, + ], + }, + { + type: AUTOMATION_CORE_ACTIONS.SET_PROPERTY, + icon: 'IconFlask', + label: 'Manage properties', + description: + 'Update existing default or custom properties for Contacts, Companies, Cards, Conversations', + }, + { + type: AUTOMATION_CORE_ACTIONS.DELAY, + icon: 'IconHourglass', + label: 'Delay', + description: + 'Delay the next action with a timeframe, a specific event or activity', + }, + { + type: AUTOMATION_CORE_ACTIONS.SEND_EMAIL, + icon: 'IconMailFast', + label: 'Send Email', + description: 'Send Email', + emailRecipientsConst: AUTOMATION_EMAIL_RECIPIENTS_TYPES, + }, + { + type: AUTOMATION_CORE_ACTIONS.WAIT_EVENT, + icon: 'IconClockPlay', + label: 'Wait event', + description: 'Delay until event is triggered', + }, + + // TODO: Uncomment this when we have a way to embed files + + // { + // type: AUTOMATION_CORE_ACTIONS.AI_AGENT, + // icon: 'IconAi', + // label: 'Ai Agent', + // description: + // 'Handle workflow conversations by topic using AI agents with embedded knowledge', + // }, +]; + +export const AUTOMATION_TRIGGERS = [ + { + type: AUTOMATION_CORE_TRIGGER_TYPES.INCOMING_WEBHOOK, + icon: 'IconWebhook', + label: 'Incoming Webhook', + description: + 'Trigger automation workflows when external systems send HTTP requests to your webhook endpoint', + isCustom: true, + }, + { + type: AUTOMATION_CORE_TRIGGER_TYPES.USER, + icon: 'IconUsers', + label: 'Team member', + description: + 'Start with a blank workflow that enrolls and is triggered off team members', + }, + { + type: AUTOMATION_CORE_TRIGGER_TYPES.CUSTOMER, + icon: 'IconUsersGroup', + label: 'Customer', + description: + 'Start with a blank workflow that enrolls and is triggered off Customers', + }, + { + type: AUTOMATION_CORE_TRIGGER_TYPES.LEAD, + icon: 'IconUsersGroup', + label: 'Lead', + description: + 'Start with a blank workflow that enrolls and is triggered off Leads', + }, + { + type: AUTOMATION_CORE_TRIGGER_TYPES.COMPANY, + icon: 'IconBuilding', + label: 'Company', + description: + 'Start with a blank workflow that enrolls and is triggered off company', + }, + { + type: AUTOMATION_CORE_TRIGGER_TYPES.FORM_SUBMISSION, + icon: 'IconForms', + label: 'Form submission', + description: + 'Start with a blank workflow that enrolls and is triggered off form submission', + }, +]; + +export const AUTOMATION_CORE_PROPERTY_TYPES = [ + { + value: 'core:customer', + label: 'Customer', + fields: [ + { label: 'ID', value: '_id' }, + { label: 'Name', value: 'name' }, + { label: 'Email', value: 'email' }, + { label: 'Phone', value: 'phone' }, + ], + }, + { + value: 'core:company', + label: 'Company', + fields: [ + { label: 'ID', value: '_id' }, + { label: 'Name', value: 'name' }, + { label: 'Email', value: 'email' }, + { label: 'Phone', value: 'phone' }, + ], + }, + { + value: 'core:product', + label: 'Product', + fields: [ + { label: 'ID', value: '_id' }, + { label: 'Name', value: 'name' }, + { label: 'Code', value: 'code' }, + ], + }, + { + value: 'core:tag', + label: 'Tag', + fields: [ + { label: 'ID', value: '_id' }, + { label: 'Name', value: 'name' }, + ], + }, +]; diff --git a/backend/erxes-api-shared/src/core-modules/automations/definitions/aiAgents.ts b/backend/erxes-api-shared/src/core-modules/automations/definitions/aiAgents.ts new file mode 100644 index 0000000000..3ae113bd9d --- /dev/null +++ b/backend/erxes-api-shared/src/core-modules/automations/definitions/aiAgents.ts @@ -0,0 +1,52 @@ +import { Schema } from 'mongoose'; +import { nanoid } from 'nanoid'; + +const aiAgentFilesSchema = new Schema( + { + id: { type: String }, + key: { type: String }, + name: { type: String }, + size: { type: Number }, + type: { type: String }, + uploadedAt: { type: Date }, + }, + { _id: false }, +); + +interface AiAgent { + name: string; + description: string; + provider: string; + prompt: string; + instructions: string; + files: { + id: string; + key: string; + name: string; + size: string; + type: string; + uploadedAt: string; + }[]; + config: any; +} + +export interface AiAgentDocument extends AiAgent { + _id: string; +} + +export const aiAgentSchema = new Schema( + { + _id: { + type: String, + default: () => nanoid(), + }, + name: { type: String }, + description: { type: String }, + provider: { type: String }, + prompt: { type: String }, + instructions: { type: String }, + files: { type: [aiAgentFilesSchema] }, + config: { type: Object }, + }, + { timestamps: true }, +); diff --git a/backend/erxes-api-shared/src/core-modules/automations/definitions/aiEmbeddings.ts b/backend/erxes-api-shared/src/core-modules/automations/definitions/aiEmbeddings.ts new file mode 100644 index 0000000000..345aa35ec5 --- /dev/null +++ b/backend/erxes-api-shared/src/core-modules/automations/definitions/aiEmbeddings.ts @@ -0,0 +1,68 @@ +import { Schema, Document } from 'mongoose'; + +export interface IAiEmbedding { + fileId: string; + fileName: string; + fileContent: string; + embedding: number[]; + bucket: string; + key: string; + embeddingModel: string; + dimensions: number; + createdAt: Date; + updatedAt: Date; +} + +export interface IAiEmbeddingDocument extends IAiEmbedding, Document { + _id: string; +} + +export const aiEmbeddingSchema = new Schema( + { + fileId: { + type: String, + required: true, + index: true, + }, + fileName: { + type: String, + required: true, + }, + fileContent: { + type: String, + required: true, + }, + embedding: { + type: [Number], + required: true, + }, + bucket: { + type: String, + required: true, + }, + key: { + type: String, + required: true, + }, + embeddingModel: { + type: String, + required: true, + default: 'bge-large-en-v1.5', + }, + dimensions: { + type: Number, + required: true, + default: 1024, + }, + }, + { + timestamps: true, + collection: 'ai_embeddings', + }, +); + +// Create indexes for better query performance +aiEmbeddingSchema.index({ fileId: 1 }); +aiEmbeddingSchema.index({ fileName: 1 }); +aiEmbeddingSchema.index({ createdAt: -1 }); +aiEmbeddingSchema.index({ updatedAt: -1 }); diff --git a/backend/erxes-api-shared/src/core-modules/automations/definitions/automations.ts b/backend/erxes-api-shared/src/core-modules/automations/definitions/automations.ts index 0fade6850e..a1690693c5 100644 --- a/backend/erxes-api-shared/src/core-modules/automations/definitions/automations.ts +++ b/backend/erxes-api-shared/src/core-modules/automations/definitions/automations.ts @@ -1,13 +1,17 @@ import { Document, Schema } from 'mongoose'; import { AUTOMATION_STATUSES } from '../constants'; -export type IActionsMap = { [key: string]: IAction }; +export type IAutomationActionsMap = { [key: string]: IAutomationAction }; -export interface IAction { +// type for values +type TAutomationStatus = + (typeof AUTOMATION_STATUSES)[keyof typeof AUTOMATION_STATUSES]; + +export interface IAutomationAction { id: string; type: string; nextActionId?: string; - config?: any; + config?: TConfig; style?: any; icon?: string; label?: string; @@ -15,16 +19,7 @@ export interface IAction { workflowId?: string; } -export type TriggerType = - | 'customer' - | 'company' - | 'deal' - | 'task' - | 'purchase' - | 'ticket' - | 'conversation'; - -export interface ITrigger { +export interface IAutomationTrigger { id: string; type: string; actionId?: string; @@ -33,7 +28,8 @@ export interface ITrigger { reEnrollment: boolean; reEnrollmentRules: string[]; dateConfig: any; - }; + [key: string]: any; + } & TConfig; style?: any; icon?: string; label?: string; @@ -44,9 +40,9 @@ export interface ITrigger { export interface IAutomation { name: string; - status: string; - triggers: ITrigger[]; - actions: IAction[]; + status: TAutomationStatus; + triggers: IAutomationTrigger[]; + actions: IAutomationAction[]; createdAt: Date; createdBy: string; updatedAt: Date; @@ -95,12 +91,24 @@ const actionSchema = new Schema( { _id: false }, ); +const workflowSchema = new Schema( + { + id: { type: String, required: true }, + automationId: { type: String, required: true }, + name: { type: String, required: true }, + description: { type: String, required: true }, + config: { type: Object }, + position: { type: Object }, + }, + { _id: false }, +); + export const automationSchema = new Schema({ - _id: { type: Schema.Types.ObjectId }, name: { type: String, required: true }, status: { type: String, default: AUTOMATION_STATUSES.DRAFT }, triggers: { type: [triggerSchema] }, actions: { type: [actionSchema] }, + workflows: { type: [workflowSchema] }, createdAt: { type: Date, default: new Date(), diff --git a/backend/erxes-api-shared/src/core-modules/automations/definitions/index.ts b/backend/erxes-api-shared/src/core-modules/automations/definitions/index.ts index 7673a78d89..8289a6e00e 100644 --- a/backend/erxes-api-shared/src/core-modules/automations/definitions/index.ts +++ b/backend/erxes-api-shared/src/core-modules/automations/definitions/index.ts @@ -1,2 +1,4 @@ export * from './automations'; export * from './executions'; +export * from './aiAgents'; +export * from './aiEmbeddings'; diff --git a/backend/erxes-api-shared/src/core-modules/automations/types.ts b/backend/erxes-api-shared/src/core-modules/automations/types.ts index 84e40426dd..f71c44d364 100644 --- a/backend/erxes-api-shared/src/core-modules/automations/types.ts +++ b/backend/erxes-api-shared/src/core-modules/automations/types.ts @@ -1,6 +1,10 @@ -import { IAction, ITrigger, IAutomationExecution } from './definitions'; +import { + IAutomationAction, + IAutomationTrigger, + IAutomationExecution, +} from './definitions'; -type IContext = { +export type IAutomationContext = { subdomain: string; processId?: string; }; @@ -55,13 +59,13 @@ export type AutomationConstants = IAutomationTriggersActionsConfig & { export interface AutomationWorkers { receiveActions?: ( - context: IContext, + context: IAutomationContext, args: { moduleName: string; collectionType: string; actionType: string; triggerType: string; - action: IAction; + action: IAutomationAction; execution: { _id: string } & IAutomationExecution; }, ) => Promise<{ @@ -75,15 +79,21 @@ export interface AutomationWorkers { }; }>; - getRecipientsEmails?: (context: IContext, args: any) => Promise; - replacePlaceHolders?: (context: IContext, args: any) => Promise; + getRecipientsEmails?: ( + context: IAutomationContext, + args: any, + ) => Promise; + replacePlaceHolders?: ( + context: IAutomationContext, + args: any, + ) => Promise; checkCustomTrigger?: ( - context: IContext, + context: IAutomationContext, args: { moduleName: string; collectionType: string; automationId: string; - trigger: ITrigger; + trigger: IAutomationTrigger; target: TTarget; config: TConfig; }, @@ -142,23 +152,58 @@ export interface IPropertyProps { relatedItems: any[]; triggerType?: string; } +export enum EXECUTE_WAIT_TYPES { + DELAY = 'delay', + IS_IN_SEGMENT = 'isInSegment', + CHECK_OBJECT = 'checkObject', + WEBHOOK = 'webhook', +} + +export type TAutomationExecutionDelay = { + subdomain: string; + waitFor: number; + timeUnit: 'minute' | 'hour' | 'day' | 'month' | 'year'; + startWaitingDate?: Date; +}; + +export type TAutomationExecutionCheckObject = { + contentType?: string; + shouldCheckOptionalConnect?: boolean; + targetId?: string; + expectedState: Record; + propertyName: string; + expectedStateConjunction?: 'every' | 'some'; + timeout?: Date; +}; + +export type TAutomationExecutionIsInSegment = { + targetId: string; + segmentId: string; +}; + +export type TAutomationExecutionWebhook = { + endpoint: string; + secret: string; + schema: any; +}; export type AutomationExecutionSetWaitCondition = - | { - type: 'delay'; - subdomain: string; - waitFor: number; - timeUnit: 'minute' | 'hour' | 'day' | 'month' | 'year'; - startWaitingDate?: Date; - } - | { - type: 'checkObject'; - contentType?: string; - shouldCheckOptionalConnect?: boolean; - targetId?: string; - expectedState: Record; - propertyName: string; - expectedStateConjunction?: 'every' | 'some'; - timeout?: Date; - } - | { type: 'isInSegment'; targetId: string; segmentId: string }; + | ({ + type: EXECUTE_WAIT_TYPES.DELAY; + } & TAutomationExecutionDelay) + | ({ + type: EXECUTE_WAIT_TYPES.CHECK_OBJECT; + } & TAutomationExecutionCheckObject) + | ({ + type: EXECUTE_WAIT_TYPES.IS_IN_SEGMENT; + } & TAutomationExecutionIsInSegment) + | ({ + type: EXECUTE_WAIT_TYPES.WEBHOOK; + } & TAutomationExecutionWebhook); + +export enum TAutomationProducers { + RECEIVE_ACTIONS = 'receiveActions', + GET_RECIPIENTS_EMAILS = 'getRecipientsEmails', + REPLACE_PLACEHOLDERS = 'replacePlaceHolders', + CHECK_CUSTOM_TRIGGER = 'checkCustomTrigger', +} diff --git a/backend/erxes-api-shared/src/core-modules/automations/utils.ts b/backend/erxes-api-shared/src/core-modules/automations/utils.ts index 063f43f314..77b0db9a00 100644 --- a/backend/erxes-api-shared/src/core-modules/automations/utils.ts +++ b/backend/erxes-api-shared/src/core-modules/automations/utils.ts @@ -1,12 +1,13 @@ import moment from 'moment'; +import { pluralFormation, sendCoreModuleProducer } from '../../utils'; +import { sendTRPCMessage } from '../../utils/trpc'; import { AUTOMATION_PROPERTY_OPERATORS, STATIC_PLACEHOLDER } from './constants'; -import { pluralFormation, sendWorkerMessage } from '../../utils'; import { IPerValueProps, IPropertyProps, IReplacePlaceholdersProps, + TAutomationProducers, } from './types'; -import { sendTRPCMessage } from '../../utils/trpc'; export const splitType = (type: string) => { return type.replace(/\./g, ':').split(':'); @@ -182,16 +183,16 @@ const getPerValue = async ({ value = ( - await sendWorkerMessage({ + await sendCoreModuleProducer({ + moduleName: 'automations', pluginName: relatedPluginName, - queueName: 'automations', - jobName: 'replacePlaceHolders', - subdomain, - data: { + producerName: TAutomationProducers.REPLACE_PLACEHOLDERS, + input: { execution, target, config: { value }, }, + defaultValue: value, }) )?.value || value; } diff --git a/backend/erxes-api-shared/src/core-modules/automations/worker.ts b/backend/erxes-api-shared/src/core-modules/automations/worker.ts index a3d0b9dabc..abea83b4de 100644 --- a/backend/erxes-api-shared/src/core-modules/automations/worker.ts +++ b/backend/erxes-api-shared/src/core-modules/automations/worker.ts @@ -1,53 +1,77 @@ +import { initializePluginConfig } from '../../utils'; +import { createTRPCContext } from '../../utils/trpc'; import { - createMQWorkerWithListeners, - initializePluginConfig, - redis -} from '../../utils'; -import { AutomationConfigs } from './types'; + AutomationConfigs, + IAutomationContext, + TAutomationProducers, +} from './types'; +import { Express } from 'express'; +import * as trpcExpress from '@trpc/server/adapters/express'; +import { AnyProcedure, initTRPC } from '@trpc/server'; +import { z } from 'zod'; +import { nanoid } from 'nanoid'; export const startAutomations = async ( + app: Express, pluginName: string, config: AutomationConfigs, ) => { + console.log('startAutomations', pluginName, config); await initializePluginConfig(pluginName, 'automations', config); + const t = initTRPC.context().create(); - return new Promise((resolve, reject) => { - try { - createMQWorkerWithListeners( - pluginName, - 'automations', - async ({ name, id, data: jobData }) => { - try { - const { subdomain, data } = jobData; - - if (!subdomain) { - throw new Error('You should provide subdomain on message'); - } - - const resolverName = name as keyof AutomationConfigs; - - if ( - !(name in config) || - typeof config[resolverName] !== 'function' - ) { - throw new Error(`Automations method ${name} not registered`); - } - - const resolver = config[resolverName]; - - return await resolver({ subdomain }, data); - } catch (error: any) { - console.error(`Error processing job ${id}: ${error.message}`); - throw error; - } - }, - redis, - () => { - resolve(); - }, - ); - } catch (error) { - reject(error); - } + const { + receiveActions, + getRecipientsEmails, + replacePlaceHolders, + checkCustomTrigger, + } = config || {}; + + const automationProcedures: Partial< + Record + > = {}; + + if (receiveActions) { + automationProcedures[TAutomationProducers.RECEIVE_ACTIONS] = t.procedure + .input(z.any()) + .mutation(async ({ ctx, input }) => receiveActions(ctx, input)); + } + + if (getRecipientsEmails) { + automationProcedures[TAutomationProducers.GET_RECIPIENTS_EMAILS] = + t.procedure + .input(z.any()) + .mutation(async ({ ctx, input }) => getRecipientsEmails(ctx, input)); + } + + if (replacePlaceHolders) { + automationProcedures[TAutomationProducers.REPLACE_PLACEHOLDERS] = + t.procedure + .input(z.any()) + .mutation(async ({ ctx, input }) => replacePlaceHolders(ctx, input)); + } + + if (checkCustomTrigger) { + automationProcedures[TAutomationProducers.CHECK_CUSTOM_TRIGGER] = + t.procedure + .input(z.any()) + .mutation(async ({ ctx, input }) => checkCustomTrigger(ctx, input)); + } + + const automationsRouter = t.router(automationProcedures); + + const trpcMiddleware = trpcExpress.createExpressMiddleware({ + router: automationsRouter, + createContext: createTRPCContext( + async (_subdomain, context) => { + const processId = nanoid(12); + + context.processId = processId; + + return context; + }, + ), }); + + app.use('/automations', trpcMiddleware); }; diff --git a/backend/erxes-api-shared/src/core-modules/logs/index.ts b/backend/erxes-api-shared/src/core-modules/logs/index.ts index 1282ae4881..16bc16bec7 100644 --- a/backend/erxes-api-shared/src/core-modules/logs/index.ts +++ b/backend/erxes-api-shared/src/core-modules/logs/index.ts @@ -1 +1,2 @@ export * from './definitions/logs'; +export * from './types'; diff --git a/backend/erxes-api-shared/src/core-modules/logs/types.ts b/backend/erxes-api-shared/src/core-modules/logs/types.ts new file mode 100644 index 0000000000..af6eae87a7 --- /dev/null +++ b/backend/erxes-api-shared/src/core-modules/logs/types.ts @@ -0,0 +1,7 @@ +export enum TAfterProcessProducers { + AFTER_MUTATION = 'afterMutation', + AFTER_AUTH = 'afterAuth', + AFTER_API_REQUEST = 'afterApiRequest', + AFTER_DOCUMENT_UPDATED = 'afterDocumentUpdated', + AFTER_DOCUMENT_CREATED = 'afterDocumentCreated', +} diff --git a/backend/erxes-api-shared/src/core-modules/permissions/user-actions-map.ts b/backend/erxes-api-shared/src/core-modules/permissions/user-actions-map.ts index 8b7c85b1df..774cf0295c 100644 --- a/backend/erxes-api-shared/src/core-modules/permissions/user-actions-map.ts +++ b/backend/erxes-api-shared/src/core-modules/permissions/user-actions-map.ts @@ -1,9 +1,9 @@ import { IBranchDocument, IDepartmentDocument, -} from '../../core-types/modules/structure/structure' +} from '../../core-types/modules/structure/structure'; import { - IActionMap, + IAutomationActionMap, IPermissionDocument, IUserDocument, } from '../../core-types'; @@ -14,13 +14,13 @@ export const userActionsMap = async ( userPermissions: IPermissionDocument[], groupPermissions: IPermissionDocument[], user: any, -): Promise => { +): Promise => { const totalPermissions: IPermissionDocument[] = [ ...userPermissions, ...groupPermissions, ...(user.customPermissions || []), ]; - const allowedActions: IActionMap = {}; + const allowedActions: IAutomationActionMap = {}; const check = (name: string, allowed: boolean) => { if (typeof allowedActions[name] === 'undefined') { @@ -49,11 +49,11 @@ export const userActionsMap = async ( export const getUserActionsMap = async ( user: IUserDocument, permissionsFind?: (query: any) => any, -): Promise => { +): Promise => { const key = getKey(user); const permissionCache = await redis.get(key); - let actionMap: IActionMap; + let actionMap: IAutomationActionMap; if (permissionCache && permissionCache !== '{}') { actionMap = JSON.parse(permissionCache); diff --git a/backend/erxes-api-shared/src/core-modules/segments/types.ts b/backend/erxes-api-shared/src/core-modules/segments/types.ts index 00604fb25b..ae7192c9b6 100644 --- a/backend/erxes-api-shared/src/core-modules/segments/types.ts +++ b/backend/erxes-api-shared/src/core-modules/segments/types.ts @@ -41,3 +41,10 @@ export interface IDependentService { twoWay?: boolean; associated?: boolean; } + +export enum TSegmentProducers { + PROPERTY_CONDITION_EXTENDER = 'propertyConditionExtender', + ASSOCIATION_FILTER = 'associationFilter', + INITIAL_SELECTOR = 'initialSelector', + ES_TYPES_MAP = 'esTypesMap', +} diff --git a/backend/erxes-api-shared/src/core-modules/segments/worker.ts b/backend/erxes-api-shared/src/core-modules/segments/worker.ts index 898001aefb..fc694accdd 100644 --- a/backend/erxes-api-shared/src/core-modules/segments/worker.ts +++ b/backend/erxes-api-shared/src/core-modules/segments/worker.ts @@ -1,51 +1,70 @@ -import { - createMQWorkerWithListeners, - initializePluginConfig, - keyForConfig, - redis, -} from '../../utils'; -import { SegmentConfigs } from './types'; - -export const startSegments = (pluginName: string, config: SegmentConfigs) => { - return new Promise((resolve, reject) => { - try { - createMQWorkerWithListeners( - pluginName, - 'segments', - async ({ name, id, data: jobData }) => { - try { - const { subdomain, data } = jobData; - - if (!subdomain) { - throw new Error('You should provide subdomain on message'); - } - - const resolverName = name as keyof SegmentConfigs; - - if ( - !(name in config) || - typeof config[resolverName] !== 'function' - ) { - throw new Error(`Segments method ${name} not registered`); - } - - const resolver = config[resolverName]; - - return await resolver({ subdomain }, data); - } catch (error: any) { - console.error(`Error processing job ${id}: ${error.message}`); - throw error; - } - }, - redis, - async () => { - await initializePluginConfig(pluginName, 'segments', config); - - resolve(); - }, +import { initializePluginConfig, createTRPCContext } from '../../utils'; +import { SegmentConfigs, TSegmentProducers } from './types'; +import { Express } from 'express'; +import * as trpcExpress from '@trpc/server/adapters/express'; +import { initTRPC } from '@trpc/server'; +import { nanoid } from 'nanoid'; +import { z } from 'zod'; + +export const initSegmentProducers = async ( + app: Express, + pluginName: string, + config: SegmentConfigs, +) => { + await initializePluginConfig(pluginName, 'segments', config); + + const t = initTRPC + .context<{ subdomain: string; processId: string }>() + .create(); + + const { + propertyConditionExtender, + associationFilter, + initialSelector, + esTypesMap, + } = config || {}; + + const routes: Record< + string, + ReturnType + > = {}; + + if (propertyConditionExtender) { + routes[TSegmentProducers.PROPERTY_CONDITION_EXTENDER] = t.procedure + .input(z.any()) + .mutation(async ({ ctx, input }) => + propertyConditionExtender(ctx, input), ); - } catch (error) { - reject(error); - } + } + + if (associationFilter) { + routes[TSegmentProducers.ASSOCIATION_FILTER] = t.procedure + .input(z.any()) + .mutation(async ({ ctx, input }) => associationFilter(ctx, input)); + } + + if (initialSelector) { + routes[TSegmentProducers.INITIAL_SELECTOR] = t.procedure + .input(z.any()) + .mutation(async ({ ctx, input }) => initialSelector(ctx, input)); + } + + if (esTypesMap) { + routes[TSegmentProducers.ES_TYPES_MAP] = t.procedure + .input(z.any()) + .mutation(async ({ ctx, input }) => esTypesMap(ctx, input)); + } + + const segmentsRouter = t.router(routes); + + const trpcMiddleware = trpcExpress.createExpressMiddleware({ + router: segmentsRouter, + createContext: createTRPCContext(async (_subdomain, context) => { + const processId = nanoid(12); + context.processId = processId; + return context; + }), }); + + app.use('/segments', trpcMiddleware); }; diff --git a/backend/erxes-api-shared/src/core-types/index.ts b/backend/erxes-api-shared/src/core-types/index.ts index 34963494e9..1b36768e0f 100644 --- a/backend/erxes-api-shared/src/core-types/index.ts +++ b/backend/erxes-api-shared/src/core-types/index.ts @@ -13,3 +13,4 @@ export * from './modules/team-member/structure'; export * from './modules/team-member/user'; export * from './modules/relations/relations'; export * from './modules/logs/logs'; +export * from './modules/automations/automations'; diff --git a/backend/erxes-api-shared/src/core-types/modules/automations/automations.ts b/backend/erxes-api-shared/src/core-types/modules/automations/automations.ts new file mode 100644 index 0000000000..fda71b22bc --- /dev/null +++ b/backend/erxes-api-shared/src/core-types/modules/automations/automations.ts @@ -0,0 +1,32 @@ +import { + IAutomationAction, + IAutomationExecution, + IAutomationTrigger, +} from '../../../core-modules/automations/definitions'; + +export type ICheckTriggerData = { + collectionType: string; + automationId: string; + trigger: IAutomationTrigger; + target: any; + config: any; +}; + +export type IReplacePlaceholdersData = { + target: TTarget; + config: any; + relatedValueProps: any; +}; + +export type IAutomationWorkerContext = { + models: TModels; + subdomain: string; +}; + +export type IAutomationReceiveActionData = { + action: IAutomationAction; + execution: { _id: string } & IAutomationExecution; + actionType: string; + collectionType: string; + triggerType: string; +}; diff --git a/backend/erxes-api-shared/src/core-types/modules/permissions/permission.ts b/backend/erxes-api-shared/src/core-types/modules/permissions/permission.ts index 24021f055d..350cf8edbf 100644 --- a/backend/erxes-api-shared/src/core-types/modules/permissions/permission.ts +++ b/backend/erxes-api-shared/src/core-types/modules/permissions/permission.ts @@ -31,11 +31,11 @@ export interface IUserGroup { export interface IUserGroupDocument extends IUserGroup, Document { _id: string; } -export interface IActionMap { +export interface IAutomationActionMap { [key: string]: boolean; } -export interface IActionsMap { +export interface IAutomationActionsMap { name?: string; module?: string; description?: string; @@ -52,7 +52,7 @@ export interface IPermissionParams { export interface IModuleMap { name: string; description?: string; - actions?: IActionsMap[]; + actions?: IAutomationActionsMap[]; } export interface IPermissionContext { diff --git a/backend/erxes-api-shared/src/utils/index.ts b/backend/erxes-api-shared/src/utils/index.ts index 209490d2b6..33004179b2 100644 --- a/backend/erxes-api-shared/src/utils/index.ts +++ b/backend/erxes-api-shared/src/utils/index.ts @@ -13,6 +13,7 @@ export * from './sanitize'; export * from './service-discovery'; export * from './start-plugin'; export * from './trpc'; +export * from './trpc/sendCoreModuleProducer'; export * from './utils'; export * from './mongo/cursor-util'; export * from './string'; diff --git a/backend/erxes-api-shared/src/utils/logs/index.ts b/backend/erxes-api-shared/src/utils/logs/index.ts index 482b809258..562187a80e 100644 --- a/backend/erxes-api-shared/src/utils/logs/index.ts +++ b/backend/erxes-api-shared/src/utils/logs/index.ts @@ -1,8 +1,13 @@ import { checkServiceRunning } from '../utils'; import { ILogDoc } from '../../core-types'; -import { createMQWorkerWithListeners, sendWorkerQueue } from '../mq-worker'; -import { redis } from '../redis'; +import { sendWorkerQueue } from '../mq-worker'; import { initializePluginConfig } from '../service-discovery'; +import { Express } from 'express'; +import * as trpcExpress from '@trpc/server/adapters/express'; +import { initTRPC } from '@trpc/server'; +import { createTRPCContext } from '../trpc'; +import { nanoid } from 'nanoid'; +import { z } from 'zod'; export const logHandler = async ( resolver: () => Promise | any, @@ -135,51 +140,64 @@ export interface AfterProcessConfigs { } export const startAfterProcess = async ( + app: Express, pluginName: string, config: AfterProcessConfigs, ) => { await initializePluginConfig(pluginName, 'afterProcess', config); - return new Promise((resolve, reject) => { - try { - createMQWorkerWithListeners( - pluginName, - 'afterProcess', - async ({ name, id, data: jobData }) => { - try { - const { - subdomain, - data: { processId, ...data }, - } = jobData; - - if (!subdomain) { - throw new Error('You should provide subdomain on message'); - } - - const resolverName = name as keyof AfterProcessConfigs; - - if ( - !(name in config) || - typeof config[resolverName] !== 'function' - ) { - throw new Error(`Automations method ${name} not registered`); - } - - const resolver = config[resolverName]; - - resolver({ subdomain, processId }, data); - } catch (error: any) { - console.error(`Error processing job ${id}: ${error.message}`); - throw error; - } - }, - redis, - () => { - resolve(); - }, - ); - } catch (error) { - reject(error); - } + const t = initTRPC.context().create(); + + const { + onAfterMutation, + onAfterAuth, + onAfterApiRequest, + onDocumentUpdated, + onDocumentCreated, + } = config || {}; + + const routes: Record = {}; + + if (onAfterMutation) { + routes.onAfterMutation = t.procedure + .input(z.any()) + .mutation(async ({ ctx, input }) => onAfterMutation(ctx, input)); + } + + if (onAfterAuth) { + routes.onAfterAuth = t.procedure + .input(z.any()) + .mutation(async ({ ctx, input }) => onAfterAuth(ctx, input)); + } + + if (onAfterApiRequest) { + routes.onAfterApiRequest = t.procedure + .input(z.any()) + .mutation(async ({ ctx, input }) => onAfterApiRequest(ctx, input)); + } + + if (onDocumentUpdated) { + routes.onDocumentUpdated = t.procedure + .input(z.any()) + .mutation(async ({ ctx, input }) => onDocumentUpdated(ctx, input)); + } + + if (onDocumentCreated) { + routes.onDocumentCreated = t.procedure + .input(z.any()) + .mutation(async ({ ctx, input }) => onDocumentCreated(ctx, input)); + } + + const afterProcessRouter = t.router(routes); + + const trpcMiddleware = trpcExpress.createExpressMiddleware({ + router: afterProcessRouter, + createContext: createTRPCContext(async (_subdomain, context) => { + const processId = nanoid(12); + context.processId = processId; + return context; + }), }); + + app.use('/after-process', trpcMiddleware); }; diff --git a/backend/erxes-api-shared/src/utils/mongo/generate-models.ts b/backend/erxes-api-shared/src/utils/mongo/generate-models.ts index 2e5fa0ef38..6d082cb329 100644 --- a/backend/erxes-api-shared/src/utils/mongo/generate-models.ts +++ b/backend/erxes-api-shared/src/utils/mongo/generate-models.ts @@ -3,7 +3,6 @@ import { coreModelOrganizations, getSaasCoreConnection, } from '../saas/saas-mongo-connection'; -import { isEnabled } from '../service-discovery'; import { checkServiceRunning, getEnv, getSubdomain } from '../utils'; import { startChangeStreams } from './change-stream'; import { connect } from './mongo-connection'; diff --git a/backend/erxes-api-shared/src/utils/start-plugin.ts b/backend/erxes-api-shared/src/utils/start-plugin.ts index 993736f1ed..dab281e549 100644 --- a/backend/erxes-api-shared/src/utils/start-plugin.ts +++ b/backend/erxes-api-shared/src/utils/start-plugin.ts @@ -23,7 +23,7 @@ import rateLimit from 'express-rate-limit'; import { SegmentConfigs, startAutomations, - startSegments, + initSegmentProducers, } from '../core-modules'; import { AutomationConfigs } from '../core-modules/automations/types'; import { generateApolloContext } from './apollo'; @@ -290,15 +290,15 @@ export async function startPlugin( } = configs.meta || {}; if (automations) { - await startAutomations(configs.name, automations); + await startAutomations(app, configs.name, automations); } if (segments) { - await startSegments(configs.name, segments); + await initSegmentProducers(app, configs.name, segments); } if (afterProcess) { - await startAfterProcess(configs.name, afterProcess); + await startAfterProcess(app, configs.name, afterProcess); } if (notificationModules) { diff --git a/backend/erxes-api-shared/src/utils/trpc/index.ts b/backend/erxes-api-shared/src/utils/trpc/index.ts index 8e3e0c6e96..caf7d33bc2 100644 --- a/backend/erxes-api-shared/src/utils/trpc/index.ts +++ b/backend/erxes-api-shared/src/utils/trpc/index.ts @@ -16,6 +16,7 @@ export type MessageProps = { defaultValue?: any; options?: TRPCRequestOptions; }; + export interface InterMessage { subdomain: string; data?: any; diff --git a/backend/erxes-api-shared/src/utils/trpc/sendCoreModuleProducer.ts b/backend/erxes-api-shared/src/utils/trpc/sendCoreModuleProducer.ts new file mode 100644 index 0000000000..1d70669d9f --- /dev/null +++ b/backend/erxes-api-shared/src/utils/trpc/sendCoreModuleProducer.ts @@ -0,0 +1,40 @@ +import { getPlugin, isEnabled } from '../../utils/service-discovery'; +import { + createTRPCUntypedClient, + httpBatchLink, + TRPCRequestOptions, +} from '@trpc/client'; + +type TCoreModuleProducer = { + moduleName: 'automations' | 'segments' | 'afterProcess'; + producerName: string; + method?: 'query' | 'mutation'; + pluginName: string; + input: any; + defaultValue?: any; + options?: TRPCRequestOptions; +}; + +export const sendCoreModuleProducer = async ({ + moduleName, + pluginName, + method = 'mutation', + producerName, + input, + defaultValue, + options, +}: TCoreModuleProducer): Promise => { + if (pluginName && !(await isEnabled(pluginName))) { + return defaultValue; + } + + const pluginInfo = await getPlugin(pluginName); + + const client = createTRPCUntypedClient({ + links: [httpBatchLink({ url: `${pluginInfo.address}/${moduleName}` })], + }); + + const result = await client[method](`${producerName}`, input, options); + + return result || defaultValue; +}; diff --git a/backend/plugins/frontline_api/src/meta/afterProcess.ts b/backend/plugins/frontline_api/src/meta/afterProcess.ts index 8998c5104e..5e97ec1dd0 100644 --- a/backend/plugins/frontline_api/src/meta/afterProcess.ts +++ b/backend/plugins/frontline_api/src/meta/afterProcess.ts @@ -3,7 +3,6 @@ import { AfterProcessConfigs, IAfterProcessRule } from 'erxes-api-shared/utils'; import { generateModels, IModels } from '~/connectionResolvers'; import { inboxAfterProcessWorkers } from '~/modules/inbox/meta/afterProcess'; import { debugError } from '~/modules/inbox/utils'; -import { IFacebookIntegrationDocument } from '~/modules/integrations/facebook/@types/integrations'; import { facebookAfterProcessWorkers } from '~/modules/integrations/facebook/meta/afterProcess/afterProcessWorkers'; type AfterProcessConfig = { diff --git a/backend/plugins/frontline_api/src/modules/integrations/facebook/meta/automation/constants.ts b/backend/plugins/frontline_api/src/modules/integrations/facebook/meta/automation/constants.ts index 45f7539c29..8cf63f2ef9 100644 --- a/backend/plugins/frontline_api/src/modules/integrations/facebook/meta/automation/constants.ts +++ b/backend/plugins/frontline_api/src/modules/integrations/facebook/meta/automation/constants.ts @@ -9,7 +9,7 @@ export const facebookConstants = { }, { type: 'frontline:facebook.comments.create', - icon: 'IconBubbleFilled', + icon: 'IconBrandFacebook', label: 'Send Facebook Comment', description: 'Send Facebook Comments', }, @@ -50,7 +50,7 @@ export const facebookConstants = { }, { type: 'frontline:facebook.comments', - icon: 'IconBubbleFilled', + icon: 'IconBrandFacebook', label: 'Facebook Comments', description: 'Start with a blank workflow that enrolls and is triggered off facebook comments', diff --git a/backend/plugins/frontline_api/src/modules/integrations/facebook/meta/automation/messages/index.ts b/backend/plugins/frontline_api/src/modules/integrations/facebook/meta/automation/messages/index.ts index 032699a1bb..6b58a7e424 100644 --- a/backend/plugins/frontline_api/src/modules/integrations/facebook/meta/automation/messages/index.ts +++ b/backend/plugins/frontline_api/src/modules/integrations/facebook/meta/automation/messages/index.ts @@ -2,7 +2,7 @@ import { pConversationClientMessageInserted } from '@/inbox/graphql/resolvers/mu import { debugError } from '@/integrations/facebook/debuggers'; import { checkContentConditions } from '@/integrations/facebook/meta/automation/utils/messageUtils'; import { - IAction, + IAutomationAction, IAutomationExecution, splitType, } from 'erxes-api-shared/core-modules'; @@ -83,7 +83,7 @@ export const actionCreateMessage = async ({ }: { models: IModels; subdomain: string; - action: IAction; + action: IAutomationAction; execution: { _id: string } & IAutomationExecution; }) => { const { diff --git a/backend/plugins/frontline_api/src/modules/integrations/facebook/meta/automation/types/automationTypes.ts b/backend/plugins/frontline_api/src/modules/integrations/facebook/meta/automation/types/automationTypes.ts index 9ad380b8c6..c407e5ac7a 100644 --- a/backend/plugins/frontline_api/src/modules/integrations/facebook/meta/automation/types/automationTypes.ts +++ b/backend/plugins/frontline_api/src/modules/integrations/facebook/meta/automation/types/automationTypes.ts @@ -1,9 +1,9 @@ import { IFacebookConversationMessage } from '@/integrations/facebook/@types/conversationMessages'; import { IFacebookIntegrationDocument } from '@/integrations/facebook/@types/integrations'; import { - IAction, + IAutomationAction, IAutomationExecution, - ITrigger, + IAutomationTrigger, } from 'erxes-api-shared/core-modules'; import { IModels } from '~/connectionResolvers'; @@ -13,7 +13,7 @@ export type IAutomationWorkerContext = { }; export type IAutomationReceiveActionData = { - action: IAction; + action: IAutomationAction; execution: { _id: string } & IAutomationExecution; actionType: string; collectionType: string; @@ -31,7 +31,7 @@ export type ISendMessageData = { export type ICheckTriggerData = { collectionType: string; automationId: string; - trigger: ITrigger; + trigger: IAutomationTrigger; target: any; config: any; }; diff --git a/backend/plugins/sales_api/src/main.ts b/backend/plugins/sales_api/src/main.ts index 5027e65ccf..9e320084c4 100644 --- a/backend/plugins/sales_api/src/main.ts +++ b/backend/plugins/sales_api/src/main.ts @@ -3,6 +3,8 @@ import { appRouter } from '~/trpc/init-trpc'; import resolvers from './apollo/resolvers'; import { typeDefs } from './apollo/typeDefs'; import { generateModels } from './connectionResolvers'; +import automations from '~/meta/automations'; +import segments from '~/meta/segments'; startPlugin({ name: 'sales', @@ -28,4 +30,5 @@ startPlugin({ return context; }, }, + meta: { automations, segments }, }); diff --git a/backend/plugins/sales_api/src/meta/automations.ts b/backend/plugins/sales_api/src/meta/automations.ts new file mode 100644 index 0000000000..35a7cc080d --- /dev/null +++ b/backend/plugins/sales_api/src/meta/automations.ts @@ -0,0 +1,42 @@ +import { + IAutomationReceiveActionData, + ICheckTriggerData, +} from 'erxes-api-shared/core-types'; +import { generateModels } from '~/connectionResolvers'; +import { salesAutomationHandlers } from '~/modules/sales/meta/automations/automationHandlers'; +import { salesAutomationContants } from '~/modules/sales/meta/automations/constants'; + +const modules = { + sales: salesAutomationHandlers, +}; + +type ModuleKeys = keyof typeof modules; + +export default { + constants: { + triggers: [...salesAutomationContants.triggers], + actions: [...salesAutomationContants.actions], + }, + receiveActions: async ( + { subdomain }, + { + moduleName, + ...args + }: { moduleName: string } & IAutomationReceiveActionData, + ) => { + const models = await generateModels(subdomain); + const context = { models, subdomain }; + + return modules[moduleName as ModuleKeys].receiveActions(context, args); + }, + + checkCustomTrigger: async ( + { subdomain }, + { moduleName, ...props }: { moduleName: string } & ICheckTriggerData, + ) => { + const models = await generateModels(subdomain); + const context = { models, subdomain }; + + return modules[moduleName as ModuleKeys].checkCustomTrigger(context, props); + }, +}; diff --git a/backend/plugins/sales_api/src/meta/segments.ts b/backend/plugins/sales_api/src/meta/segments.ts new file mode 100644 index 0000000000..1942d175e7 --- /dev/null +++ b/backend/plugins/sales_api/src/meta/segments.ts @@ -0,0 +1,31 @@ +import { SegmentConfigs, splitType } from 'erxes-api-shared/core-modules'; +import { salesSegments } from '~/modules/sales/segments'; + +const modules = { + sales: salesSegments, +}; + +type ModuleKeys = keyof typeof modules; + +export default { + dependentServices: [...salesSegments.dependentServices], + contentTypes: [...salesSegments.contentTypes], + propertyConditionExtender: (context, data) => { + const [_, moduleName] = splitType(data?.condition?.propertyType || ''); + return modules[moduleName as ModuleKeys].propertyConditionExtender( + context, + data, + ); + }, + associationFilter: (context, data) => { + const [_, moduleName] = splitType(data?.propertyType || ''); + return modules[moduleName as ModuleKeys].associationFilter(context, data); + }, + esTypesMap: (_, { collectionType }) => { + return modules[collectionType as ModuleKeys].esTypesMap(); + }, + initialSelector: (context, data) => { + const [_, moduleName] = splitType(data?.segment?.contentType || ''); + return modules[moduleName as ModuleKeys].initialSelector(context, data); + }, +} as SegmentConfigs; diff --git a/backend/plugins/sales_api/src/modules/sales/constants.ts b/backend/plugins/sales_api/src/modules/sales/constants.ts index 497099d561..472335cb34 100644 --- a/backend/plugins/sales_api/src/modules/sales/constants.ts +++ b/backend/plugins/sales_api/src/modules/sales/constants.ts @@ -101,3 +101,69 @@ export const CLOSE_DATE_TYPES = { }, ], }; + +export const BOARD_ITEM_EXTENDED_FIELDS = [ + { + _id: Math.random(), + name: 'boardName', + label: 'Board name', + type: 'string', + }, + { + _id: Math.random(), + name: 'pipelineName', + label: 'Pipeline name', + type: 'string', + }, + { + _id: Math.random(), + name: 'stageName', + label: 'Stage name', + type: 'string', + }, + { + _id: Math.random(), + name: 'assignedUserEmail', + label: 'Assigned user email', + type: 'string', + }, + { + _id: Math.random(), + name: 'labelIds', + label: 'Label', + type: 'string', + }, + { + _id: Math.random(), + name: 'totalAmount', + label: 'Total Amount', + type: 'number', + }, +]; + +export const BOARD_ITEM_EXPORT_EXTENDED_FIELDS = [ + { + _id: Math.random(), + name: 'totalAmount', + label: 'Total Amount', + type: 'number', + }, + { + _id: Math.random(), + name: 'totalLabelCount', + label: 'Total Label Counts', + type: 'number', + }, + { + _id: Math.random(), + name: 'stageMovedUser', + label: 'Stage Moved User', + type: 'string', + }, + { + _id: Math.random(), + name: 'internalNotes', + label: 'Internal Notes', + type: 'string', + }, +]; diff --git a/backend/plugins/sales_api/src/modules/sales/fieldUtils.ts b/backend/plugins/sales_api/src/modules/sales/fieldUtils.ts new file mode 100644 index 0000000000..39efa01098 --- /dev/null +++ b/backend/plugins/sales_api/src/modules/sales/fieldUtils.ts @@ -0,0 +1,477 @@ +import { PROBABILITY } from './constants'; +import { + BOARD_ITEM_EXPORT_EXTENDED_FIELDS, + BOARD_ITEM_EXTENDED_FIELDS, +} from '~/modules/sales/constants'; +import { generateModels, IModels } from '~/connectionResolvers'; +import { sendTRPCMessage } from 'erxes-api-shared/utils'; +import { generateFieldsFromSchema } from 'erxes-api-shared/core-modules'; + +const generateProductsOptions = async ( + name: string, + label: string, + type: string, +) => { + return { + _id: Math.random(), + name, + label, + type, + selectionConfig: { + queryName: 'products', + labelField: 'name', + }, + }; +}; + +const generateProductsCategoriesOptions = async ( + name: string, + label: string, + type: string, +) => { + return { + _id: Math.random(), + name, + label, + type, + selectionConfig: { + queryName: 'productCategories', + labelField: 'name', + }, + }; +}; + +const generateContactsOptions = async ( + name: string, + label: string, + type: string, + selectionConfig?: any, +) => { + return { + _id: Math.random(), + name, + label, + type, + selectionConfig: { + ...selectionConfig, + labelField: 'primaryEmail', + multi: true, + }, + }; +}; + +const generateUsersOptions = async ( + name: string, + label: string, + type: string, + selectionConfig: any, +) => { + return { + _id: Math.random(), + name, + label, + type, + selectionConfig: { + ...selectionConfig, + queryName: 'users', + labelField: 'email', + }, + }; +}; + +const generateStructuresOptions = async ( + name: string, + label: string, + type: string, + selectionConfig?: any, +) => { + return { + _id: Math.random(), + name, + label, + type, + selectionConfig: { + ...selectionConfig, + labelField: 'title', + }, + }; +}; + +const getStageOptions = async (models: IModels, pipelineId) => { + const stages = await models.Stages.find({ pipelineId }); + const options: Array<{ label: string; value: any }> = []; + + for (const stage of stages) { + options.push({ + value: stage._id, + label: stage.name || '', + }); + } + + return { + _id: Math.random(), + name: 'stageId', + label: 'Stage', + type: 'stage', + selectOptions: options, + }; +}; + +const getStageProbabilityOptions = () => { + return { + _id: Math.random(), + name: 'stageProbability', + label: 'Stage Probability', + type: 'probability', + selectOptions: PROBABILITY.ALL.map((prob) => ({ + value: prob, + label: prob, + })), + }; +}; + +const getPipelineLabelOptions = async (models: IModels, pipelineId) => { + const labels = await models.PipelineLabels.find({ pipelineId }); + const options: Array<{ label: string; value: any }> = []; + + for (const label of labels) { + options.push({ + value: label._id, + label: label.name, + }); + } + + return { + _id: Math.random(), + name: 'labelIds', + label: 'Labels', + type: 'label', + selectOptions: options, + }; +}; + +const generateTagOptions = async ( + subdomain, + name: string, + label: string, + type: string, +) => { + const options = await sendTRPCMessage({ + pluginName: 'core', + module: 'tags', + action: 'tagFind', + input: { + type: 'sales:deal', + }, + defaultValue: [], + }); + + return { + _id: Math.random(), + name, + label, + type, + selectOptions: options.map(({ _id, name }) => ({ + value: _id, + label: name, + })), + }; +}; + +export const generateSalesFields = async ( + subdomain: string, + models: IModels, + data, +) => { + const { type, config = {}, segmentId, usageType } = data; + + const { pipelineId } = config; + + let schema: any; + let fields: Array<{ + _id: number; + name: string; + group?: string; + label?: string; + type?: string; + validation?: string; + options?: string[]; + selectOptions?: Array<{ label: string; value: string }>; + }> = []; + + switch (type) { + case 'deal': + schema = models.Deals.schema; + break; + } + + if (usageType && usageType === 'import') { + fields = BOARD_ITEM_EXTENDED_FIELDS; + } + + if (usageType && usageType === 'export') { + fields = BOARD_ITEM_EXPORT_EXTENDED_FIELDS; + } + + if (schema) { + // generate list using customer or company schema + fields = [...(await generateFieldsFromSchema(schema, '')), ...fields]; + + for (const name of Object.keys(schema.paths)) { + const path = schema.paths[name]; + + // extend fields list using sub schema fields + if (path.schema) { + fields = [ + ...fields, + ...(await generateFieldsFromSchema(path.schema, `${name}.`)), + ]; + } + } + } + + const createdByOptions = await generateUsersOptions( + 'userId', + 'Created by', + 'user', + { multi: false }, + ); + + const modifiedByOptions = await generateUsersOptions( + 'modifiedBy', + 'Modified by', + 'user', + { multi: false }, + ); + + const assignedUserOptions = await generateUsersOptions( + 'assignedUserIds', + 'Assigned to', + 'user', + { multi: true }, + ); + + const watchedUserOptions = await generateUsersOptions( + 'watchedUserIds', + 'Watched users', + 'user', + { multi: true }, + ); + + const customersPrimaryEmailOptions = await generateContactsOptions( + 'customersEmail', + 'Customers Primary Email', + 'contact', + { + queryName: 'customers', + }, + ); + + const customersPrimaryPhoneOptions = await generateContactsOptions( + 'customersPhone', + 'Customers Primary Phone', + 'contact', + { + queryName: 'customers', + }, + ); + + const customersFullNameOptions = await generateContactsOptions( + 'customersName', + 'Customers Full Name', + 'contact', + { + queryName: 'customers', + }, + ); + + const companiesOptions = await generateContactsOptions( + 'companies', + 'Companies', + 'contact', + { + queryName: 'companies', + }, + ); + + const branchesOptions = await generateStructuresOptions( + 'branchIds', + 'Branches', + 'structure', + { queryName: 'branches' }, + ); + + const departmentsOptions = await generateStructuresOptions( + 'departmentIds', + 'Departments', + 'structure', + { queryName: 'branches' }, + ); + + const tagsOptions = await generateTagOptions( + subdomain, + 'tagIds', + 'Tags', + 'tags', + ); + + fields = [ + ...fields, + ...[ + createdByOptions, + modifiedByOptions, + assignedUserOptions, + watchedUserOptions, + customersFullNameOptions, + customersPrimaryEmailOptions, + customersPrimaryPhoneOptions, + companiesOptions, + branchesOptions, + departmentsOptions, + tagsOptions, + ], + ]; + + if (usageType === 'automations') { + fields = [ + ...fields, + { + _id: Math.random(), + name: 'createdBy.email', + label: 'Created by Email', + type: 'String', + }, + { + _id: Math.random(), + name: 'createdBy.phone', + label: 'Created by Phone', + type: 'String', + }, + { + _id: Math.random(), + name: 'createdBy.branch', + label: 'Created by Branch', + type: 'String', + }, + { + _id: Math.random(), + name: 'createdBy.department', + label: 'Created by Department', + type: 'String', + }, + { + _id: Math.random(), + name: 'customers.email', + label: 'Customers Email', + type: 'String', + }, + { + _id: Math.random(), + name: 'customers.phone', + label: 'Customers phone', + type: 'String', + }, + { + _id: Math.random(), + name: 'customers.fullName', + label: 'Customers FullName', + type: 'String', + }, + { + _id: Math.random(), + name: 'link', + label: 'Link', + type: 'String', + }, + { + _id: Math.random(), + name: 'pipelineLabels', + label: 'Pipeline Labels', + type: 'String', + }, + ]; + } + + if (type === 'deal' && usageType !== 'export') { + const productOptions = await generateProductsOptions( + 'productsData.productId', + 'Product', + 'product', + ); + + const productsCategoriesOptions = await generateProductsCategoriesOptions( + 'productsData.categoryId', + 'Product Categories', + 'select', + ); + + fields = [ + ...fields, + ...[productOptions, productsCategoriesOptions, assignedUserOptions], + ]; + } + + if (type === 'deal' && usageType === 'export') { + const extendFieldsExport = [ + { _id: Math.random(), name: 'productsData.name', label: 'Product Name' }, + { _id: Math.random(), name: 'productsData.code', label: 'Product Code' }, + { _id: Math.random(), name: 'productsData.branch', label: 'Branch' }, + { + _id: Math.random(), + name: 'productsData.department', + label: 'Department', + }, + ]; + + fields = [...fields, ...extendFieldsExport]; + } + + if (usageType === 'export') { + const extendExport = [ + { _id: Math.random(), name: 'boardId', label: 'Board' }, + { _id: Math.random(), name: 'pipelineId', label: 'Pipeline' }, + { _id: Math.random(), name: 'labelIds', label: 'Label' }, + ]; + + fields = [...fields, ...extendExport]; + } + + if (segmentId || pipelineId) { + const segment = segmentId + ? await sendTRPCMessage({ + pluginName: 'core', + module: 'segments', + action: 'findOne', + input: { _id: segmentId }, + }) + : null; + + const labelOptions = await getPipelineLabelOptions( + models, + pipelineId || (segment ? segment.pipelineId : null), + ); + + const stageOptions = await getStageOptions( + models, + pipelineId || (segment ? segment.pipelineId : null), + ); + + // Add probability options here + const probabilityOptions = getStageProbabilityOptions(); + + fields = [...fields, stageOptions, labelOptions, probabilityOptions]; + } else { + const stageOptions = { + _id: Math.random(), + name: 'stageId', + label: 'Stage', + type: 'stage', + }; + //Add probability options in else + const probabilityOptions = getStageProbabilityOptions(); + + fields = [...fields, stageOptions, probabilityOptions]; + } + + return fields; +}; diff --git a/backend/plugins/sales_api/src/modules/sales/meta/automations/action/createAction.ts b/backend/plugins/sales_api/src/modules/sales/meta/automations/action/createAction.ts new file mode 100644 index 0000000000..5712d02a77 --- /dev/null +++ b/backend/plugins/sales_api/src/modules/sales/meta/automations/action/createAction.ts @@ -0,0 +1,188 @@ +// import { replacePlaceHolders } from '@erxes/api-utils/src/automations'; +// import { sendCoreMessage } from '../messageBroker'; +// import { getRelatedValue } from './getRelatedValue'; +// import { itemsAdd } from '../graphql/resolvers/mutations/utils'; +// import { getCollection } from '../models/utils'; + +import { replacePlaceHolders } from 'erxes-api-shared/core-modules'; +import { sendTRPCMessage } from 'erxes-api-shared/utils'; +import { IModels } from '~/connectionResolvers'; +import { getRelatedValue } from '~/modules/sales/meta/automations/action/getRelatedValue'; +import { itemsAdd } from '~/modules/sales/utils'; + +export const actionCreate = async ({ + models, + subdomain, + action, + execution, + collectionType, +}: { + models: IModels; + subdomain: string; + action: any; + execution: any; + collectionType: string; +}) => { + const { config = {} } = action; + let { target, triggerType } = execution || {}; + let relatedValueProps = {}; + + let newData = action.config.assignedTo + ? await replacePlaceHolders({ + models, + subdomain, + customResolver: { resolver: getRelatedValue, isRelated: false }, + + actionData: { assignedTo: action.config.assignedTo }, + target: { ...target, type: (triggerType || '').replace('sales:', '') }, + }) + : {}; + + delete action.config.assignedTo; + + if (!!config.customers) { + relatedValueProps['customers'] = { key: '_id' }; + target.customers = config.customers; + } + if (!!config.companies) { + relatedValueProps['companies'] = { key: '_id' }; + target.companies = config.companies; + } + + newData = { + ...newData, + ...(await replacePlaceHolders({ + models, + subdomain, + customResolver: { resolver: getRelatedValue, props: relatedValueProps }, + actionData: action.config, + target: { ...target, type: (triggerType || '').replace('sales:', '') }, + })), + }; + + if (execution.target.userId) { + newData.userId = execution.target.userId; + } + + if (execution.triggerType === 'inbox:conversation') { + newData.sourceConversationIds = [execution.targetId]; + } + + if ( + ['core:customer', 'core:lead'].includes(execution.triggerType) && + execution.target.isFormSubmission + ) { + newData.sourceConversationIds = [execution.target.conversationId]; + } + + if (newData.hasOwnProperty('assignedTo')) { + newData.assignedUserIds = newData.assignedTo.trim().split(', '); + } + + if (newData.hasOwnProperty('labelIds')) { + newData.labelIds = newData.labelIds.trim().split(', '); + } + + if (newData.hasOwnProperty('cardName')) { + newData.name = newData.cardName; + } + + if (config.hasOwnProperty('stageId')) { + newData.stageId = config.stageId; + } + + if (!!newData?.customers) { + newData.customerIds = generateIds(newData.customers); + } + if (!!newData?.companies) { + newData.companyIds = generateIds(newData.companies); + } + + if (Object.keys(newData).some((key) => key.startsWith('customFieldsData'))) { + const customFieldsData: Array<{ field: string; value: string }> = []; + + const fieldKeys = Object.keys(newData).filter((key) => + key.startsWith('customFieldsData'), + ); + + for (const fieldKey of fieldKeys) { + const [, fieldId] = fieldKey.split('.'); + + customFieldsData.push({ + field: fieldId, + value: newData[fieldKey], + }); + } + newData.customFieldsData = customFieldsData; + } + + if (newData.hasOwnProperty('attachments')) { + const [serviceName, itemType] = triggerType.split(':'); + if (serviceName === 'sales') { + const item = await models.Deals.findOne({ _id: target._id }); + newData.attachments = item?.attachments; + } + } + + try { + const item = await itemsAdd( + models, + subdomain, + newData as any, + collectionType, + models, + ); + + if (execution.triggerType === 'inbox:conversation') { + await sendTRPCMessage({ + pluginName: 'core', + module: 'conformities', + action: 'addConformity', + input: { + mainType: 'customer', + mainTypeId: execution.target.customerId, + relType: `${collectionType}`, + relTypeId: item._id, + }, + }); + } else { + const mainType = execution.triggerType.split(':')[1]; + + await sendTRPCMessage({ + pluginName: 'core', + module: 'conformities', + action: 'addConformity', + input: { + mainType: mainType.replace('lead', 'customer'), + mainTypeId: execution.targetId, + relType: `${collectionType}`, + relTypeId: item._id, + }, + }); + } + + return { + name: item.name, + itemId: item._id, + stageId: item.stageId, + pipelineId: newData.pipelineId, + boardId: newData.boardId, + }; + } catch (e) { + return { error: e.message }; + } +}; + +const generateIds = (value) => { + const arr = value.split(', '); + + if (Array.isArray(arr)) { + return arr; + } + + if (!arr.match(/\{\{\s*([^}]+)\s*\}\}/g)) { + return [arr]; + } + + return []; +}; diff --git a/backend/plugins/sales_api/src/modules/sales/meta/automations/action/createChecklist.ts b/backend/plugins/sales_api/src/modules/sales/meta/automations/action/createChecklist.ts new file mode 100644 index 0000000000..76be51bf6d --- /dev/null +++ b/backend/plugins/sales_api/src/modules/sales/meta/automations/action/createChecklist.ts @@ -0,0 +1,48 @@ +import { IModels } from '~/connectionResolvers'; + +export const createChecklist = async (models: IModels, execution, action) => { + const { actions = [] } = execution; + + const prevAction = actions[actions.length - 1]; + + const object: any = { + contentType: 'deal', + }; + + if ( + prevAction?.actionType === 'sales:deal.create' && + prevAction?.nextActionId === action.id && + prevAction?.result?.itemId + ) { + object.contentTypeId = prevAction.result.itemId; + } else { + if (!execution?.triggerType?.includes('sales:deal')) { + throw new Error('Unsupported trigger type'); + } + + object.contentTypeId = execution?.targetId; + } + + const { items, name } = action?.config || {}; + + const checklist = await models.Checklists.create({ + ...object, + title: name, + createdDate: new Date(), + }); + + await models.ChecklistItems.insertMany( + items.map(({ label }, i) => ({ + createdDate: new Date(), + checklistId: checklist._id, + content: label, + order: i, + })), + ); + + if (items.some((item) => !!item?.isChecked)) { + return { result: checklist.toObject(), objToWait: {} }; + } + + return { result: checklist.toObject() }; +}; diff --git a/backend/plugins/sales_api/src/modules/sales/meta/automations/action/getItems.ts b/backend/plugins/sales_api/src/modules/sales/meta/automations/action/getItems.ts new file mode 100644 index 0000000000..8c62f7175b --- /dev/null +++ b/backend/plugins/sales_api/src/modules/sales/meta/automations/action/getItems.ts @@ -0,0 +1,102 @@ +import { sendTRPCMessage } from 'erxes-api-shared/utils'; +import { generateModels } from '~/connectionResolvers'; + +const relatedServices = ( + subdomain: string, + triggerCollectionType: string, + moduleCollectionType: string, + target: any, +) => [ + { + name: 'contacts', + filter: async () => { + if (target.isFormSubmission) { + return { sourceConversationIds: { $in: [target.conversationId] } }; + } + + const relTypeIds = await sendTRPCMessage({ + pluginName: 'core', + module: 'conformities', + action: 'savedConformity', + input: { + mainType: triggerCollectionType, + mainTypeId: target._id, + relTypes: [moduleCollectionType], + }, + defaultValue: [], + }); + + if (!relTypeIds.length) { + return; + } + + return { _id: { $in: relTypeIds } }; + }, + }, + { + name: 'inbox', + filter: async () => ({ + sourceConversationIds: { $in: [target._id] }, + }), + }, +]; + +export const getItems = async ( + subdomain: string, + module: string, + execution: any, + triggerType: string, +) => { + const { target } = execution; + + if (module === triggerType) { + return [target]; + } + + const [moduleService, moduleCollectionType] = module.split(':'); + const [triggerService, triggerCollectionType] = triggerType.split(':'); + + const models = await generateModels(subdomain); + + if (moduleService === triggerService) { + const relTypeIds = await sendTRPCMessage({ + pluginName: 'core', + module: 'conformities', + action: 'savedConformity', + input: { + mainType: triggerCollectionType, + mainTypeId: target._id, + relTypes: [moduleCollectionType], + }, + }); + + return models.Deals.find({ _id: { $in: relTypeIds } }); + } + + // search trigger service relation from relatedServices + const relatedService = relatedServices( + subdomain, + triggerCollectionType, + moduleCollectionType, + target, + ).find((service) => service.name === triggerService); + + let filter: any = await relatedService?.filter(); + + if (!relatedService) { + // send message to trigger service to get related value + filter = await sendTRPCMessage({ + pluginName: triggerService, + module: 'conformities', + action: 'getModuleRelation', + input: { + module, + triggerType, + target, + }, + defaultValue: null, + }); + } + + return filter ? await models.Deals.find(filter) : []; +}; diff --git a/backend/plugins/sales_api/src/modules/sales/meta/automations/action/getRelatedValue.ts b/backend/plugins/sales_api/src/modules/sales/meta/automations/action/getRelatedValue.ts new file mode 100644 index 0000000000..4605ff0c97 --- /dev/null +++ b/backend/plugins/sales_api/src/modules/sales/meta/automations/action/getRelatedValue.ts @@ -0,0 +1,405 @@ +import { IUser } from 'erxes-api-shared/core-types'; +import { getEnv, sendTRPCMessage } from 'erxes-api-shared/utils'; +import moment from 'moment'; +import { IModels } from '~/connectionResolvers'; + +export const getRelatedValue = async ( + models: IModels, + subdomain: string, + target: any = {}, + targetKey = '', + relatedValueProps: any = {}, +) => { + if ( + [ + 'userId', + 'assignedUserId', + 'closedUserId', + 'ownerId', + 'createdBy', + ].includes(targetKey) + ) { + const user = await sendTRPCMessage({ + pluginName: 'core', + method: 'query', + module: 'users', + action: 'findOne', + input: { _id: target[targetKey] }, + }); + + if (!!relatedValueProps[targetKey]) { + const key = relatedValueProps[targetKey]?.key; + return user[key]; + } + + return ( + (user && ((user.detail && user.detail.fullName) || user.email)) || '' + ); + } + + if ( + ['participatedUserIds', 'assignedUserIds', 'watchedUserIds'].includes( + targetKey, + ) + ) { + const users = await sendTRPCMessage({ + pluginName: 'core', + method: 'query', + module: 'users', + action: 'findOne', + input: { + query: { + _id: { $in: target[targetKey] }, + }, + }, + }); + + if (!!relatedValueProps[targetKey]) { + const { key, filter } = relatedValueProps[targetKey] || {}; + return users + .filter((user) => (filter ? user[filter.key] === filter.value : user)) + .map((user) => user[key]) + .join(', '); + } + + return ( + users.map( + (user) => (user.detail && user.detail.fullName) || user.email, + ) || [] + ).join(', '); + } + + if (targetKey === 'tagIds') { + const tags = await sendTRPCMessage({ + pluginName: 'core', + method: 'query', + module: 'tags', + action: 'tagFind', + input: { _id: { $in: target[targetKey] } }, + }); + + return (tags.map((tag) => tag.name) || []).join(', '); + } + + if (targetKey === 'labelIds') { + const labels = await models.PipelineLabels.find({ + _id: { $in: target[targetKey] }, + }); + + return (labels.map((label) => label.name) || []).join(', '); + } + + if (['initialStageId', 'stageId'].includes(targetKey)) { + const stage = await models.Stages.findOne({ + _id: target[targetKey], + }); + + return (stage && stage.name) || ''; + } + + if (['sourceConversationIds'].includes(targetKey)) { + const conversations = await sendTRPCMessage({ + pluginName: 'inbox', + module: 'conversations', + action: 'find', + input: { _id: { $in: target[targetKey] } }, + }); + + return (conversations.map((c) => c.content) || []).join(', '); + } + + if (['customers', 'companies'].includes(targetKey)) { + const relTypeConst = { + companies: 'company', + customers: 'customer', + }; + + const contactIds = await sendTRPCMessage({ + pluginName: 'core', + module: 'conformities', + action: 'savedConformity', + input: { + mainType: 'deal', + mainTypeId: target._id, + relTypes: [relTypeConst[targetKey]], + }, + }); + + const upperCasedTargetKey = + targetKey.charAt(0).toUpperCase() + targetKey.slice(1); + + const activeContacts = await sendTRPCMessage({ + pluginName: 'core', + module: targetKey, + action: `findActive${upperCasedTargetKey}`, + input: { selector: { _id: { $in: contactIds } } }, + }); + + if (relatedValueProps && !!relatedValueProps[targetKey]) { + const { key, filter } = relatedValueProps[targetKey] || {}; + return activeContacts + .filter((contacts) => + filter ? contacts[filter.key] === filter.value : contacts, + ) + .map((contacts) => contacts[key]) + .join(', '); + } + + const result = activeContacts.map((contact) => contact?._id).join(', '); + return result; + } + + if (targetKey.includes('productsData')) { + const [_parentFieldName, childFieldName] = targetKey.split('.'); + + if (childFieldName === 'amount') { + return generateTotalAmount(target.productsData); + } + } + + if ((targetKey || '').includes('createdBy.')) { + return await generateCreatedByFieldValue({ subdomain, target, targetKey }); + } + + if (targetKey.includes('customers.')) { + return await generateCustomersFielValue({ target, targetKey, subdomain }); + } + if (targetKey.includes('customFieldsData.')) { + return await generateCustomFieldsDataValue({ + target, + targetKey, + subdomain, + }); + } + + if (targetKey === 'link') { + const DOMAIN = getEnv({ + name: 'DOMAIN', + }); + + const stage = await models.Stages.getStage(target.stageId); + const pipeline = await models.Pipelines.getPipeline(stage.pipelineId); + const board = await models.Boards.getBoard(pipeline.boardId); + return `${DOMAIN}/deal/board?id=${board._id}&pipelineId=${pipeline._id}&itemId=${target._id}`; + } + + if (targetKey === 'pipelineLabels') { + const labels = await models.PipelineLabels.find({ + _id: { $in: target?.labelIds || [] }, + }).lean(); + + return `${labels.map(({ name }) => name).filter(Boolean) || '-'}`; + } + + if ( + [ + 'createdAt', + 'startDate', + 'closeDate', + 'stageChangedDate', + 'modifiedAt', + ].includes(targetKey) + ) { + const dateValue = targetKey[targetKey]; + return moment(dateValue).format('YYYY-MM-DD HH:mm'); + } + + return false; +}; + +const generateCustomFieldsDataValue = async ({ + targetKey, + subdomain, + target, +}: { + targetKey: string; + subdomain: string; + target: any; +}) => { + const [_, fieldId] = targetKey.split('customFieldsData.'); + const customFieldData = (target?.customFieldsData || []).find( + ({ field }) => field === fieldId, + ); + + if (!customFieldData) { + return; + } + + const field = await sendTRPCMessage({ + pluginName: 'core', + module: 'fields', + action: 'findOne', + input: { + query: { + _id: fieldId, + $or: [ + { type: 'users' }, + { type: 'input', validation: { $in: ['date', 'datetime'] } }, + ], + }, + }, + }); + + if (!field) { + return; + } + + if (field?.type === 'users') { + const users: IUser[] = await sendTRPCMessage({ + pluginName: 'core', + module: 'users', + action: 'find', + input: { + query: { _id: { $in: customFieldData?.value || [] } }, + fields: { details: 1 }, + }, + defaultValue: [], + }); + + return users + .map( + ({ details }) => + `${details?.firstName || ''} ${details?.lastName || ''}`, + ) + .filter(Boolean) + .join(', '); + } + const isISODate = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/.test( + customFieldData?.value, + ); + + if ( + field?.type === 'input' && + ['date', 'datetime'].includes(field.validation) && + isISODate + ) { + return moment(customFieldData.value).format('YYYY-MM-DD HH:mm'); + } +}; + +const generateCustomersFielValue = async ({ + targetKey, + subdomain, + target, +}: { + targetKey: string; + subdomain: string; + target: any; +}) => { + const [_, fieldName] = targetKey.split('.'); + + const customerIds = await sendTRPCMessage({ + pluginName: 'core', + module: 'conformities', + action: 'savedConformity', + input: { + mainType: 'deal', + mainTypeId: target._id, + relTypes: ['customer'], + }, + defaultValue: [], + }); + + const customers: any[] = + (await sendTRPCMessage({ + pluginName: 'core', + module: 'customers', + action: 'find', + input: { _id: { $in: customerIds } }, + defaultValue: [], + })) || []; + + if (fieldName === 'email') { + return customers + .map((customer) => + customer?.primaryEmail + ? customer?.primaryEmail + : (customer?.emails || [])[0]?.email, + ) + .filter(Boolean) + .join(', '); + } + if (fieldName === 'phone') { + return customers + .map((customer) => + customer?.primaryPhone + ? customer?.primaryPhone + : (customer?.phones || [])[0]?.phone, + ) + .filter(Boolean) + .join(', '); + } + if (fieldName === 'fullName') { + return customers + .map(({ firstName = '', lastName = '' }) => `${firstName} ${lastName}`) + .filter(Boolean) + .join(', '); + } +}; + +const generateCreatedByFieldValue = async ({ + targetKey, + subdomain, + target, +}: { + targetKey: string; + subdomain: string; + target: any; +}) => { + const [_, userField] = targetKey.split('.'); + const user = await sendTRPCMessage({ + pluginName: 'core', + module: 'users', + action: 'findOne', + input: { _id: target?.userId }, + }); + if (userField === 'branch') { + const branches = await sendTRPCMessage({ + pluginName: 'core', + module: 'branches', + action: 'find', + input: { _id: user?.branchIds || [] }, + defaultValue: [], + }); + + const branch = (branches || [])[0] || {}; + + return `${branch?.title || ''}`; + } + if (userField === 'department') { + const departments = await sendTRPCMessage({ + pluginName: 'core', + module: 'departments', + action: 'find', + input: { _id: user?.departmentIds || [] }, + defaultValue: [], + }); + + const department = (departments || [])[0] || {}; + + return `${department?.title || ''}`; + } + + if (userField === 'phone') { + const { details } = (user || {}) as IUser; + + return `${details?.operatorPhone || ''}`; + } + if (userField === 'email') { + return `${user?.email || '-'}`; + } +}; + +const generateTotalAmount = (productsData) => { + let totalAmount = 0; + + (productsData || []).forEach((product) => { + if (product.tickUsed) { + return; + } + + totalAmount += product?.amount || 0; + }); + + return totalAmount; +}; diff --git a/backend/plugins/sales_api/src/modules/sales/meta/automations/automationHandlers.ts b/backend/plugins/sales_api/src/modules/sales/meta/automations/automationHandlers.ts new file mode 100644 index 0000000000..c90db19f59 --- /dev/null +++ b/backend/plugins/sales_api/src/modules/sales/meta/automations/automationHandlers.ts @@ -0,0 +1,106 @@ +import { + replacePlaceHolders, + setProperty, +} from 'erxes-api-shared/core-modules'; +import { + IAutomationReceiveActionData, + IAutomationWorkerContext, + ICheckTriggerData, + IReplacePlaceholdersData, +} from 'erxes-api-shared/core-types'; +import { generateModels, IModels } from '~/connectionResolvers'; +import { actionCreate } from '~/modules/sales/meta/automations/action/createAction'; +import { createChecklist } from '~/modules/sales/meta/automations/action/createChecklist'; +import { getItems } from '~/modules/sales/meta/automations/action/getItems'; +import { getRelatedValue } from '~/modules/sales/meta/automations/action/getRelatedValue'; +import { checkTriggerDealStageProbality } from '~/modules/sales/meta/automations/trigger/checkStageProbalityTrigger'; + +export const salesAutomationHandlers = { + checkCustomTrigger: async ( + { subdomain }: IAutomationWorkerContext, + data: ICheckTriggerData, + ) => { + const { collectionType, target, config } = data; + const models = await generateModels(subdomain); + + if (collectionType === 'deal.probability') { + return checkTriggerDealStageProbality({ models, target, config }); + } + + return false; + }, + receiveActions: async ( + { models, subdomain }: IAutomationWorkerContext, + { + action, + execution, + actionType, + collectionType, + triggerType, + }: IAutomationReceiveActionData, + ) => { + if (actionType === 'create') { + if (collectionType === 'checklist') { + return createChecklist(models, execution, action); + } + + const result = await actionCreate({ + models, + subdomain, + action, + execution, + collectionType, + }); + + return { result }; + } + + const { module, rules } = action.config; + + const relatedItems = await getItems( + subdomain, + module, + execution, + triggerType.split('.')[0], + ); + + const result = await setProperty({ + models, + subdomain, + getRelatedValue, + module, + rules, + execution, + relatedItems, + triggerType, + }); + + return { result }; + }, + replacePlaceHolders: async ( + { models, subdomain }: IAutomationWorkerContext, + data: IReplacePlaceholdersData, + ) => { + const { relatedValueProps, config, target } = data; + + return await replacePlaceHolders({ + models, + subdomain, + customResolver: { resolver: getRelatedValue, props: relatedValueProps }, + actionData: config, + target: { + ...target, + ['createdBy.department']: '-', + ['createdBy.branch']: '-', + ['createdBy.phone']: '-', + ['createdBy.email']: '-', + ['customers.email']: '-', + ['customers.phone']: '-', + ['customers.fullName']: '-', + link: '-', + pipelineLabels: '-', + }, + complexFields: ['productsData'], + }); + }, +}; diff --git a/backend/plugins/sales_api/src/modules/sales/meta/automations/constants.ts b/backend/plugins/sales_api/src/modules/sales/meta/automations/constants.ts new file mode 100644 index 0000000000..90976b11df --- /dev/null +++ b/backend/plugins/sales_api/src/modules/sales/meta/automations/constants.ts @@ -0,0 +1,36 @@ +export const salesAutomationContants = { + triggers: [ + { + type: 'sales:deal', + icon: 'IconPigMoney', + label: 'Sales pipeline', + description: + 'Start with a blank workflow that enrolls and is triggered off sales pipeline item', + }, + { + type: 'sales:deal.probability', + icon: 'IconPigMoney', + label: 'Sales pipelines stage probability based', + description: + 'Start with a blank workflow that triggered off sales pipeline item stage probability', + isCustom: true, + }, + ], + actions: [ + { + type: 'sales:deal.create', + icon: 'IconPigMoney', + label: 'Create deal', + description: 'Create deal', + isAvailable: true, + isAvailableOptionalConnect: true, + }, + { + type: 'sales:checklist.create', + icon: 'IconPigMoney', + label: 'Create sales checklist', + description: 'Create sales checklist', + isAvailable: true, + }, + ], +}; diff --git a/backend/plugins/sales_api/src/modules/sales/meta/automations/trigger/checkStageProbalityTrigger.ts b/backend/plugins/sales_api/src/modules/sales/meta/automations/trigger/checkStageProbalityTrigger.ts new file mode 100644 index 0000000000..d4d53345b2 --- /dev/null +++ b/backend/plugins/sales_api/src/modules/sales/meta/automations/trigger/checkStageProbalityTrigger.ts @@ -0,0 +1,51 @@ +import { IModels } from '~/connectionResolvers'; +import { IDeal } from '~/modules/sales/@types'; + +export const checkTriggerDealStageProbality = async ({ + models, + target, + config, +}: { + models: IModels; + target: IDeal; + config: any; +}) => { + const { boardId, pipelineId, stageId, probability } = config || {}; + + if (!probability) { + return false; + } + + const filter = { _id: target?.stageId, probability }; + if (stageId && stageId !== target.stageId) { + return false; + } + + if (!stageId && pipelineId) { + const stageIds = await models.Stages.find({ + pipelineId, + probability, + }).distinct('_id'); + + if (!stageIds.find((stageId) => target.stageId === stageId)) { + return false; + } + } + + if (!stageId && !pipelineId && boardId) { + const pipelineIds = await models.Pipelines.find({ boardId }).distinct( + '_id', + ); + + const stageIds = await models.Stages.find({ + pipelineId: { $in: pipelineIds }, + probability, + }).distinct('_id'); + + if (!stageIds.find((stageId) => target.stageId === stageId)) { + return false; + } + } + + return !!(await models.Stages.findOne(filter)); +}; diff --git a/backend/plugins/sales_api/src/modules/sales/segments.ts b/backend/plugins/sales_api/src/modules/sales/segments.ts new file mode 100644 index 0000000000..cea06bd59d --- /dev/null +++ b/backend/plugins/sales_api/src/modules/sales/segments.ts @@ -0,0 +1,240 @@ +import { + gatherAssociatedTypes, + getContentType, + getEsIndexByContentType, + getPluginName, +} from 'erxes-api-shared/core-modules'; +import { + fetchByQueryWithScroll, + sendTRPCMessage, +} from 'erxes-api-shared/utils'; +import { generateModels, IModels } from '~/connectionResolvers'; + +export const generateConditionStageIds = async ( + models: IModels, + { + boardId, + pipelineId, + options, + }: { + boardId?: string; + pipelineId?: string; + options?: any; + }, +) => { + let pipelineIds: string[] = []; + + if (options && options.pipelineId) { + pipelineIds = [options.pipelineId]; + } + + if (boardId && (!options || !options.pipelineId)) { + const board = await models.Boards.getBoard(boardId); + + const pipelines = await models.Pipelines.find( + { + _id: { + $in: pipelineId ? [pipelineId] : board.pipelines || [], + }, + }, + { _id: 1 }, + ); + + pipelineIds = pipelines.map((p) => p._id); + } + + const stages = await models.Stages.find( + { pipelineId: pipelineIds }, + { _id: 1 }, + ); + + return stages.map((s) => s._id); +}; + +export const salesSegments = { + dependentServices: [ + { + name: 'core', + // types: ['company', 'customer', 'lead'], + twoWay: true, + associated: true, + }, + { name: 'tickets', twoWay: true, associated: true }, + { name: 'tasks', twoWay: true, associated: true }, + { name: 'purchases', twoWay: true, associated: true }, + { name: 'inbox', twoWay: true }, + { + name: 'cars', + twoWay: true, + associated: true, + }, + ], + + contentTypes: [ + { + type: 'deal', + description: 'Deal', + esIndex: 'deals', + }, + ], + + propertyConditionExtender: async ({ subdomain }, { condition, ...rest }) => { + const models = await generateModels(subdomain); + + let positive; + let ignoreThisPostiveQuery; + + const stageIds = await generateConditionStageIds(models, { + boardId: condition.boardId, + pipelineId: condition.pipelineId, + }); + + if (stageIds.length > 0) { + positive = { + terms: { + stageId: stageIds, + }, + }; + } + + if (condition.propertyName === 'stageProbability') { + const { propertyType, propertyValue } = condition || {}; + + const [_serviceName, contentType] = propertyType.split(':'); + + const stageIds = await models.Stages.find({ + type: contentType, + probability: propertyValue, + }) + .distinct('_id') + .lean(); + + positive = { + terms: { + stageId: stageIds, + }, + }; + ignoreThisPostiveQuery = true; + } + + const productIds = await generateProductsCategoryProductIds( + subdomain, + condition, + ); + if (productIds.length > 0) { + positive = { + bool: { + should: productIds.map((productId) => ({ + match: { 'productsData.productId': productId }, + })), + }, + }; + + if (condition.propertyName == 'productsData.categoryId') { + ignoreThisPostiveQuery = true; + } + } + + return { data: { positive, ignoreThisPostiveQuery }, status: 'success' }; + }, + + associationFilter: async ( + { subdomain }, + { mainType, propertyType, positiveQuery, negativeQuery }, + ) => { + const associatedTypes: string[] = await gatherAssociatedTypes(mainType); + + let ids: string[] = []; + + if (associatedTypes.includes(propertyType)) { + const mainTypeIds = await fetchByQueryWithScroll({ + subdomain, + index: await getEsIndexByContentType(propertyType), + positiveQuery, + negativeQuery, + }); + + ids = await sendTRPCMessage({ + pluginName: 'core', + module: 'conformities', + action: 'filterConformity', + input: { + mainType: getContentType(propertyType), + mainTypeIds, + relType: getContentType(mainType), + }, + }); + } else { + const pluginName = getPluginName(propertyType); + + if (pluginName === 'sales') { + return { data: [], status: 'error' }; + } + + ids = []; + await sendTRPCMessage({ + pluginName, + module: 'segments', + action: 'associationFilter', + input: { + mainType, + propertyType, + positiveQuery, + negativeQuery, + }, + defaultValue: [], + }); + } + + return { data: ids, status: 'success' }; + }, + + esTypesMap: async () => { + return { data: { typesMap: {} }, status: 'success' }; + }, + + initialSelector: async ({ subdomain }, { segment, options }) => { + const models = await generateModels(subdomain); + + let positive; + + const config = segment.config || {}; + + const stageIds = await generateConditionStageIds(models, { + boardId: config.boardId, + pipelineId: config.pipelineId, + options, + }); + + if (stageIds.length > 0) { + positive = { terms: { stageId: stageIds } }; + } + + return { data: { positive }, status: 'success' }; + }, +}; + +const generateProductsCategoryProductIds = async (subdomain, condition) => { + let productCategoryIds: string[] = []; + + const { propertyName, propertyValue } = condition; + if (propertyName === 'productsData.categoryId') { + productCategoryIds.push(propertyValue); + + const products = await sendTRPCMessage({ + pluginName: 'core', + module: 'products', + action: 'find', + input: { + categoryIds: [...new Set(productCategoryIds)], + fields: { _id: 1 }, + }, + defaultValue: [], + }); + + const productIds = products.map((product) => product._id); + + return productIds; + } + return []; +}; diff --git a/backend/plugins/sales_api/src/modules/sales/utils.ts b/backend/plugins/sales_api/src/modules/sales/utils.ts index d78e82e108..6cdee5fdb9 100644 --- a/backend/plugins/sales_api/src/modules/sales/utils.ts +++ b/backend/plugins/sales_api/src/modules/sales/utils.ts @@ -20,7 +20,7 @@ import { } from './@types'; import { CLOSE_DATE_TYPES, SALES_STATUSES } from './constants'; -import { can } from 'erxes-api-shared/core-modules'; +import { can, sendNotification } from 'erxes-api-shared/core-modules'; import { IUserDocument } from 'erxes-api-shared/core-types'; import moment from 'moment'; import { DeleteResult } from 'mongoose'; @@ -1564,3 +1564,47 @@ export const getAmountsMap = async ( }); return amountsMap; }; + +export const itemsAdd = async ( + models: IModels, + subdomain: string, + doc: IDeal & { + proccessId: string; + aboveItemId: string; + }, + type: string, + createModel: any, + user?: IUserDocument, + docModifier?: any, +) => { + doc.initialStageId = doc.stageId; + doc.watchedUserIds = user && [user._id]; + + const modifiedDoc = docModifier ? docModifier(doc) : doc; + + const extendedDoc = { + ...modifiedDoc, + modifiedBy: user && user._id, + userId: user ? user._id : doc.userId, + order: await getNewOrder({ + collection: models.Deals, + stageId: doc.stageId, + aboveItemId: doc.aboveItemId, + }), + }; + + if (extendedDoc.customFieldsData) { + // clean custom field values + extendedDoc.customFieldsData = await sendTRPCMessage({ + pluginName: 'core', + module: 'fields', + action: 'prepareCustomFieldsData', + input: extendedDoc.customFieldsData, + defaultValue: [], + }); + } + + const item = await createModel(extendedDoc); + + return item; +}; diff --git a/backend/plugins/sales_api/src/trpc/init-trpc.ts b/backend/plugins/sales_api/src/trpc/init-trpc.ts index c878a37790..88ea626e02 100644 --- a/backend/plugins/sales_api/src/trpc/init-trpc.ts +++ b/backend/plugins/sales_api/src/trpc/init-trpc.ts @@ -1,12 +1,39 @@ import { dealTrpcRouter } from '@/sales/trpc/deal'; import { initTRPC } from '@trpc/server'; import { ITRPCContext } from 'erxes-api-shared/utils'; +import { z } from 'zod'; import { IModels } from '~/connectionResolvers'; +import { generateSalesFields } from '~/modules/sales/fieldUtils'; export type SalesTRPCContext = ITRPCContext<{ models: IModels }>; const t = initTRPC.context().create(); -export const appRouter = t.mergeRouters(dealTrpcRouter); +export const appRouter = t.mergeRouters( + dealTrpcRouter, + t.router({ + fields: t.router({ + getFieldList: t.procedure + .input( + z.object({ + moduleType: z.string(), + collectionType: z.string().optional(), + segmentId: z.string().optional(), + usageType: z.string().optional(), + config: z.record(z.any()).optional(), + }), + ) + .query(async ({ ctx, input }) => { + const { models, subdomain } = ctx; + const { moduleType } = input; + if (moduleType === 'sales') { + return await generateSalesFields(subdomain, models, input); + } + + return []; + }), + }), + }), +); export type AppRouter = typeof appRouter; diff --git a/backend/services/automations/src/ai/detectConversationType.ts b/backend/services/automations/src/ai/detectConversationType.ts new file mode 100644 index 0000000000..9d22d2ed57 --- /dev/null +++ b/backend/services/automations/src/ai/detectConversationType.ts @@ -0,0 +1,44 @@ +import { generateTextCF } from '@/ai/generateTextCF'; + +export async function detectConversationType( + question: string, +): Promise { + try { + const prompt = ` + Analyze the following user message and determine if it's a general conversation (like greetings, casual chat, asking for help) or a specific question that would require looking up information from documents/files. + + User Message: "${question}" + + Please respond with ONLY "general" if it's general conversation, or "specific" if it's a question that would need document/file information. + + Examples: + - "Hello" → general + - "How are you?" → general + - "What can you do?" → general + - "What is erxes?" → specific + - "What are the features?" → specific + - "How to install?" → specific + - "Tell me about the requirements" → specific + + response me as just one word is it casual question or not + Response:`; + + const response = await generateTextCF(prompt); + return response.toLowerCase().includes('general'); + } catch (error) { + console.error('Error detecting conversation type:', error); + + // Fallback to simple detection for common cases + const questionLower = question.toLowerCase().trim(); + const generalPatterns = [ + 'hello', + 'hi', + 'hey', + 'how are you', + "what's up", + 'help', + ]; + + return generalPatterns.some((pattern) => questionLower.includes(pattern)); + } +} diff --git a/backend/services/automations/src/ai/embedTextCFChunks.ts b/backend/services/automations/src/ai/embedTextCFChunks.ts new file mode 100644 index 0000000000..bda67eda99 --- /dev/null +++ b/backend/services/automations/src/ai/embedTextCFChunks.ts @@ -0,0 +1,170 @@ +import { chunkText } from '@/utils/cloudflare'; + +export async function embedTextCFChunks( + text: string, + onProgress?: (info: { + total: number; + processed: number; + failed: number; + currentIndex?: number; + message?: string; + }) => void, +): Promise { + const { CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID } = process.env; + + if (!CLOUDFLARE_API_TOKEN) { + throw new Error('CLOUDFLARE_API_TOKEN is required for AI embedding'); + } + + const MAX_CONCURRENCY = 4; + const MAX_RETRIES = 3; + const INITIAL_BACKOFF_MS = 500; + const REQUEST_TIMEOUT_MS = 30000; + + async function requestWithRetry(payload: { text: string }, index: number) { + let attempt = 0; + while (attempt <= MAX_RETRIES) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const resp = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/run/@cf/baai/bge-large-en-v1.5`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + signal: controller.signal, + }, + ); + clearTimeout(timeout); + + if (!resp.ok) { + if (resp.status >= 400 && resp.status < 500 && resp.status !== 429) { + const bodyText = await resp.text().catch(() => ''); + throw new Error( + `Cloudflare AI API error on chunk ${index + 1}: ${resp.status} ${ + resp.statusText + }${bodyText ? ` - ${bodyText}` : ''}`, + ); + } + throw new Error( + `Transient Cloudflare AI API error on chunk ${index + 1}: ${ + resp.status + } ${resp.statusText}`, + ); + } + + const data = (await resp.json().catch(() => null)) as { + result?: { data?: number[][] }; + } | null; + if (!data || !data.result || !Array.isArray(data.result.data)) { + throw new Error(`Invalid response structure on chunk ${index + 1}`); + } + const vector = data.result.data[0]; + if (!Array.isArray(vector)) { + throw new Error(`Missing embedding vector on chunk ${index + 1}`); + } + return vector as number[]; + } catch (error) { + clearTimeout(timeout); + attempt += 1; + if (attempt > MAX_RETRIES) { + throw error; + } + const backoff = INITIAL_BACKOFF_MS * Math.pow(2, attempt - 1); + const jitter = Math.floor(Math.random() * 200); + await new Promise((resolve) => setTimeout(resolve, backoff + jitter)); + } + } + return [] as number[]; + } + + const chunks = chunkText(text, 1000).filter((c) => c.trim().length > 0); + const logProgress = (info: { + total: number; + processed: number; + failed: number; + currentIndex?: number; + message?: string; + }) => { + if (onProgress) { + onProgress(info); + return; + } + const percent = info.total + ? Math.floor(((info.processed + info.failed) / info.total) * 100) + : 0; + const parts = [ + `embed-chunks: ${percent}% (${info.processed}/${info.total})`, + info.failed ? `failed=${info.failed}` : '', + typeof info.currentIndex === 'number' + ? `chunk=${info.currentIndex + 1}` + : '', + info.message || '', + ].filter(Boolean); + // eslint-disable-next-line no-console + console.log(parts.join(' | ')); + }; + + let nextIndex = 0; + let results: number[] = Array(chunks.length); + const errors: { index: number; error: unknown }[] = []; + let processed = 0; + let failed = 0; + + logProgress({ total: chunks.length, processed, failed, message: 'start' }); + + async function worker() { + while (true) { + const currentIndex = nextIndex; + if (currentIndex >= chunks.length) return; + nextIndex += 1; + try { + const vector = await requestWithRetry( + { text: chunks[currentIndex] }, + currentIndex, + ); + results = [...results, ...vector]; + processed += 1; + logProgress({ + total: chunks.length, + processed, + failed, + currentIndex, + message: 'ok', + }); + } catch (err) { + errors.push({ index: currentIndex, error: err }); + failed += 1; + logProgress({ + total: chunks.length, + processed, + failed, + currentIndex, + message: `error: ${String((err as Error)?.message || err)}`, + }); + } + } + } + + const workers = Array.from( + { length: Math.min(MAX_CONCURRENCY, chunks.length) }, + () => worker(), + ); + await Promise.all(workers); + + if (errors.length > 0) { + const first = errors[0]; + throw new Error( + `Failed to embed chunks: ${String( + (first.error as Error)?.message || first.error, + )}`, + ); + } + + logProgress({ total: chunks.length, processed, failed, message: 'done' }); + return results; +} diff --git a/backend/services/automations/src/ai/fielEmbedding.ts b/backend/services/automations/src/ai/fielEmbedding.ts new file mode 100644 index 0000000000..00b13a04a4 --- /dev/null +++ b/backend/services/automations/src/ai/fielEmbedding.ts @@ -0,0 +1,563 @@ +import { embedTextCFChunks } from '@/ai/embedTextCFChunks'; +import { generateTextCF } from '@/ai/generateTextCF'; +import { + getPropmtMessageTemplate, + getObjectGenerationPrompt, +} from '@/ai/propmts'; +import { getFileAsStringFromCF } from '@/utils/cloudflare'; +import { getEnv } from 'erxes-api-shared/utils'; +export interface IFileEmbedding { + fileId: string; + fileName: string; + fileContent: string; + embedding: number[]; + createdAt: Date; +} + +export interface IAiAgentTopic { + id: string; + topicName: string; + prompt: string; +} + +export interface IAiAgentObjectField { + id: string; + fieldName: string; + prompt: string; + dataType: 'string' | 'number' | 'boolean' | 'object' | 'array'; + validation: string; +} + +export interface IGeneratedObject { + [key: string]: any; +} + +export class FileEmbeddingService { + /** + * Embed uploaded file content and store for AI agent use + */ + async embedUploadedFile( + models: any, + key: string, + fileName?: string, + ): Promise { + try { + // Get file content from Cloudflare R2 + const CLOUDFLARE_BUCKET_NAME = getEnv({ name: 'CLOUDFLARE_BUCKET_NAME' }); + const fileContent = await getFileAsStringFromCF( + CLOUDFLARE_BUCKET_NAME, + key, + ); + + // Generate embedding for the file content + const embedding = await embedTextCFChunks(fileContent); + + return { + fileId: key, + fileName: fileName || key, + fileContent, + embedding, + createdAt: new Date(), + }; + } catch (error) { + throw new Error(`Failed to embed file: ${error.message}`); + } + } + + /** + * Generate AI agent message based on file content and user query + */ + async generateAgentMessage( + fileEmbeddings: IFileEmbedding[], + userQuery: string, + ): Promise { + try { + // Use Cloudflare AI for advanced semantic search and response generation + const response = await this.generateAdvancedResponse( + fileEmbeddings, + userQuery, + ); + return response; + } catch (error) { + throw new Error(`Failed to generate agent message: ${error.message}`); + } + } + + /** + * Classify user query to determine topic for flow splitting + * Returns the most relevant topic ID + */ + async classifyUserQueryForFlow( + userQuery: string, + topics: IAiAgentTopic[], + ): Promise { + try { + if (!topics || topics.length === 0) { + throw new Error('No topics provided for classification'); + } + + const topicId = await this.generateReponceUsingTopics(userQuery, topics); + + return topicId; + } catch (error) { + throw new Error( + `Failed to classify user query for flow: ${error.message}`, + ); + } + } + + /** + * Generate structured object data based on user query and defined object fields + * Returns a structured object with the specified fields + */ + async generateObjectFromQuery( + userQuery: string, + objectFields: IAiAgentObjectField[], + ): Promise { + try { + if (!objectFields || objectFields.length === 0) { + throw new Error('No object fields provided for generation'); + } + + const generatedObject = await this.generateObjectUsingFields( + userQuery, + objectFields, + ); + + return generatedObject; + } catch (error) { + throw new Error(`Failed to generate object from query: ${error.message}`); + } + } + + /** + * Advanced response generation using Cloudflare AI for semantic search and context retrieval + */ + private async generateAdvancedResponse( + fileEmbeddings: IFileEmbedding[], + userQuery: string, + ): Promise { + try { + // Create a comprehensive prompt for advanced document analysis + const documentContexts = fileEmbeddings + .map((embedding, index) => { + const cleanContent = this.cleanDocumentContent(embedding.fileContent); + return `Document ${index + 1} (${ + embedding.fileName + }):\n${cleanContent.substring(0, 1000)}`; + }) + .join('\n\n'); + + const advancedPrompt = getPropmtMessageTemplate( + documentContexts, + userQuery, + ); + + const response = await generateTextCF(advancedPrompt); + return response; + } catch (error) { + console.error('Error in advanced response generation:', error); + + // Fallback to simpler approach if advanced method fails + return await this.generateFallbackResponse(fileEmbeddings, userQuery); + } + } + + /** + * Classify user query against available topics to determine flow direction + * Returns the most relevant topic ID for flow splitting + */ + private async generateReponceUsingTopics( + userQuery: string, + topics: IAiAgentTopic[], + ): Promise { + try { + // Create topic classification prompt without documents + const topicClassificationPrompt = this.createTopicClassificationPrompt( + userQuery, + topics, + ); + + // Generate response using Cloudflare AI + const response = await generateTextCF(topicClassificationPrompt); + + // Parse the response to extract topic ID + const topicId = this.extractTopicIdFromResponse(response, topics); + + return topicId; + } catch (error) { + console.error('Error in topic classification:', error); + + // Fallback to first topic if classification fails + return topics.length > 0 ? topics[0].id : ''; + } + } + + /** + * Generate structured object data using defined fields + * Returns a structured object with the specified fields populated + */ + private async generateObjectUsingFields( + userQuery: string, + objectFields: IAiAgentObjectField[], + ): Promise { + try { + // Create object generation prompt using centralized prompt function + const objectGenerationPrompt = getObjectGenerationPrompt( + userQuery, + objectFields, + ); + + // Generate response using Cloudflare AI + const response = await generateTextCF(objectGenerationPrompt); + + // Parse the response to extract structured object + const generatedObject = this.extractObjectFromResponse( + response, + objectFields, + ); + + return generatedObject; + } catch (error) { + console.error('Error in object generation:', error); + + // Fallback to empty object with default values + return this.createFallbackObject(objectFields); + } + } + + /** + * Create a prompt for topic classification based on user query and available topics + */ + private createTopicClassificationPrompt( + userQuery: string, + topics: IAiAgentTopic[], + ): string { + const topicsList = topics + .map( + (topic, index) => + `${index + 1}. ID: ${topic.id}, Name: ${ + topic.topicName + }, Description: ${topic.prompt}`, + ) + .join('\n'); + + return `You are an enterprise-grade AI assistant that specializes in topic classification for workflow routing. Your task is to analyze the user's query against available topics to determine the most relevant topic for flow splitting. + +Available Topics: +${topicsList} + +User Query: "${userQuery}" + +Instructions: +1. Analyze the user query to understand their intent and the type of information they're seeking +2. Match the query against the available topics based on: + - Topic name relevance + - Topic description/prompt alignment + - User intent and context +3. Select the most relevant topic ID that best matches the user's query +4. Consider the semantic meaning and context of the query + +Your response should be in the following format: +TOPIC_ID: [selected topic ID] + +Please analyze and respond with only the topic ID:`; + } + + /** + * Extract topic ID from AI response + */ + private extractTopicIdFromResponse( + response: string, + topics: IAiAgentTopic[], + ): string { + try { + // Look for TOPIC_ID: pattern in the response + const topicIdMatch = response.match(/TOPIC_ID:\s*([^\n\r]+)/i); + if (topicIdMatch) { + const extractedId = topicIdMatch[1].trim(); + // Validate that the extracted ID exists in the topics array + const validTopic = topics.find((topic) => topic.id === extractedId); + if (validTopic) { + return extractedId; + } + } + + // Fallback: try to find topic ID by matching topic names in the response + for (const topic of topics) { + if ( + response.toLowerCase().includes(topic.topicName.toLowerCase()) || + response.toLowerCase().includes(topic.id.toLowerCase()) + ) { + return topic.id; + } + } + + // If no topic found, return the first topic as fallback + return topics.length > 0 ? topics[0].id : ''; + } catch (error) { + console.error('Error extracting topic ID from response:', error); + return topics.length > 0 ? topics[0].id : ''; + } + } + + /** + * Extract structured object from AI response + */ + private extractObjectFromResponse( + response: string, + objectFields: IAiAgentObjectField[], + ): IGeneratedObject { + try { + // Try to find JSON object in the response + const jsonMatch = response.match(/\{[\s\S]*\}/); + if (jsonMatch) { + const jsonString = jsonMatch[0]; + const parsedObject = JSON.parse(jsonString); + + // Validate and convert types according to field definitions + const validatedObject = this.validateAndConvertObject( + parsedObject, + objectFields, + ); + return validatedObject; + } + + // Fallback: create object from field names mentioned in response + return this.createObjectFromTextResponse(response, objectFields); + } catch (error) { + console.error('Error extracting object from response:', error); + return this.createFallbackObject(objectFields); + } + } + + /** + * Validate and convert object values according to field definitions + */ + private validateAndConvertObject( + obj: any, + objectFields: IAiAgentObjectField[], + ): IGeneratedObject { + const validatedObject: IGeneratedObject = {}; + + for (const field of objectFields) { + const value = obj[field.fieldName]; + + if (value !== undefined && value !== null && value !== '') { + validatedObject[field.fieldName] = this.convertValueToType( + value, + field.dataType, + ); + } else { + // Return undefined for fields not mentioned in the query + validatedObject[field.fieldName] = undefined; + } + } + + return validatedObject; + } + + /** + * Convert value to specified data type + */ + private convertValueToType(value: any, dataType: string): any { + switch (dataType) { + case 'string': + return String(value); + case 'number': + const num = Number(value); + return isNaN(num) ? 0 : num; + case 'boolean': + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + return ( + value.toLowerCase() === 'true' || value.toLowerCase() === 'yes' + ); + } + return Boolean(value); + case 'array': + return Array.isArray(value) ? value : [value]; + case 'object': + return typeof value === 'object' && value !== null ? value : {}; + default: + return value; + } + } + + /** + * Create object from text response when JSON parsing fails + */ + private createObjectFromTextResponse( + response: string, + objectFields: IAiAgentObjectField[], + ): IGeneratedObject { + const obj: IGeneratedObject = {}; + + for (const field of objectFields) { + // Try to find field value in the response text + const fieldPattern = new RegExp( + `${field.fieldName}[\\s:]+([^\\n\\r]+)`, + 'i', + ); + const match = response.match(fieldPattern); + + if (match && match[1].trim() !== '') { + obj[field.fieldName] = this.convertValueToType( + match[1].trim(), + field.dataType, + ); + } else { + // Return undefined for fields not mentioned in the query + obj[field.fieldName] = undefined; + } + } + + return obj; + } + + /** + * Create fallback object with undefined values when parsing fails + */ + private createFallbackObject( + objectFields: IAiAgentObjectField[], + ): IGeneratedObject { + const obj: IGeneratedObject = {}; + + for (const field of objectFields) { + obj[field.fieldName] = undefined; + } + + return obj; + } + + /** + * Fallback response generation for when advanced method fails + */ + private async generateFallbackResponse( + fileEmbeddings: IFileEmbedding[], + userQuery: string, + ): Promise { + try { + // Use Cloudflare AI to find the most relevant document + const relevancePrompt = `Given the following documents and user question, identify which document is most relevant and extract the key information needed to answer the question. + +Documents: +${fileEmbeddings + .map( + (embedding, index) => + `Document ${index + 1}: ${this.cleanDocumentContent( + embedding.fileContent, + ).substring(0, 500)}`, + ) + .join('\n\n')} + +User Question: "${userQuery}" + +Please identify the most relevant document and provide a helpful answer based on its content. If no document is relevant, state this clearly.`; + + const response = await generateTextCF(relevancePrompt); + return response; + } catch (error) { + console.error('Error in fallback response generation:', error); + return "I apologize, but I'm unable to process your question at the moment. Please try again or rephrase your question."; + } + } + + /** + * Clean and process document content for better AI processing + */ + private cleanDocumentContent(content: string): string { + // Remove binary markers and XML tags + let cleanText = content + .replace(/PK[^\s]*/g, '') // Remove PK markers + .replace(/<\?xml[^>]*>/g, '') // Remove XML declarations + .replace(/<[^>]*>/g, '') // Remove all XML tags + .replace(/[^\w\s.,!?\-()]/g, ' ') // Keep readable characters + .replace(/\s+/g, ' ') // Normalize whitespace + .trim(); + + // If still too much binary content, try to extract meaningful text + if (cleanText.length < 50) { + const readablePattern = /[A-Za-z]{3,}/g; + const words = cleanText.match(readablePattern); + if (words && words.length > 0) { + cleanText = words.join(' '); + } else { + cleanText = + "This appears to be a binary file or document that couldn't be properly extracted."; + } + } + + return cleanText; + } + + /** + * Generate AI response using Cloudflare AI + */ + private async generateAIResponse( + content: string, + userQuery: string, + ): Promise { + try { + // Create a prompt for the AI model + const prompt = `Based on the following document content, please answer the user's question in a helpful and informative way. Provide a clear, concise response that directly addresses their question.Document Content:${content.substring( + 0, + 2000, + )} // Limit content to avoid token limits + User Question: ${userQuery} + + Please provide a helpful answer based on the document content:`; + const response = await generateTextCF(prompt); + + return response; + } catch (error) { + console.error('Error generating AI response:', error); + return `I apologize, but I encountered an error while processing your question. Please try again or rephrase your question.`; + } + } + + /** + * Generate general response using agent's prompt and instructions + */ + async generateGeneralResponse( + prompt: string, + instructions: string, + userQuery: string, + ): Promise { + try { + // Create a context-aware prompt for general conversation + const systemPrompt = `You are an AI agent with the following characteristics: + + ${prompt ? `Agent Prompt: ${prompt}` : ''} + ${instructions ? `Agent Instructions: ${instructions}` : ''} + + Please respond to the user's message in a helpful, friendly, and contextually appropriate way. If the user is greeting you, respond warmly. If they're asking for help, provide assistance based on your capabilities. If they're asking about your role, explain what you can do. + + User Message: ${userQuery} + + Please provide a natural, conversational response:`; + + const response = await generateTextCF(systemPrompt); + return response; + } catch (error) { + console.error('Error generating general response:', error); + + // Use Cloudflare AI for fallback response as well + try { + const fallbackPrompt = `You are a helpful AI agent. The user sent this message: "${userQuery}" + + Please provide a friendly, helpful response. If it's a greeting, respond warmly. If they're asking for help, explain what you can do. If it's unclear, ask how you can help them. + + User Message: ${userQuery} + + Please provide a natural response:`; + + const fallbackResponse = await generateTextCF(fallbackPrompt); + return fallbackResponse; + } catch (fallbackError) { + console.error('Error generating fallback response:', fallbackError); + return `Hello! I'm your AI agent. I'm here to help you with questions and tasks. How can I assist you today?`; + } + } + } +} diff --git a/backend/services/automations/src/ai/generateAiAgentMessage.ts b/backend/services/automations/src/ai/generateAiAgentMessage.ts new file mode 100644 index 0000000000..47294a1b53 --- /dev/null +++ b/backend/services/automations/src/ai/generateAiAgentMessage.ts @@ -0,0 +1,79 @@ +import { detectConversationType } from '@/ai/detectConversationType'; +import { FileEmbeddingService } from '@/ai/fielEmbedding'; +import { IModels } from '@/connectionResolver'; + +export const generateAiAgentMessage = async ( + models: IModels, + question: string, + agentId: string, +) => { + const fileEmbeddingService = new FileEmbeddingService(); + + const agent = await models.AiAgents.findById(agentId); + if (!agent) { + throw new Error('AI Agent not found'); + } + const isGeneralConversation = await detectConversationType(question); + + if (isGeneralConversation) { + // Use agent's prompt and instructions for general conversation + const message = await fileEmbeddingService.generateGeneralResponse( + agent.prompt || '', + agent.instructions || '', + question, + ); + + return { + message, + relevantFile: null, + similarity: 0, + }; + } else { + // Use file embeddings for specific questions + const files = agent.files || []; + const fileIds = files.map((f) => f.id).filter(Boolean); + + if (fileIds.length === 0) { + return { + message: + "I don't have any files to reference for this question. Please upload files and start training, or ask me a general question.", + relevantFile: null, + similarity: 0, + }; + } + + const fileEmbeddings = await models.AiEmbeddings.find({ + fileId: { $in: fileIds }, + }); + + if (fileEmbeddings.length === 0) { + return { + message: + "I don't have any trained files to reference for this question. Please start AI training first, or ask me a general question.", + relevantFile: null, + similarity: 0, + }; + } + + // Convert to IFileEmbedding format + const embeddings = fileEmbeddings.map((embedding) => ({ + fileId: embedding.fileId, + fileName: embedding.fileName, + fileContent: embedding.fileContent, + embedding: embedding.embedding, + createdAt: embedding.createdAt, + })); + + // Generate agent message based on files + const message = await fileEmbeddingService.generateAgentMessage( + embeddings, + question, + ); + + return { + message, + relevantFile: fileEmbeddings[0]?.fileName || null, + similarity: 0.8, + }; + } +}; diff --git a/backend/services/automations/src/ai/generateTextCF.ts b/backend/services/automations/src/ai/generateTextCF.ts new file mode 100644 index 0000000000..509ad472c1 --- /dev/null +++ b/backend/services/automations/src/ai/generateTextCF.ts @@ -0,0 +1,40 @@ +export async function generateTextCF(prompt: string) { + const { CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID } = process.env; + + if (!CLOUDFLARE_API_TOKEN) { + throw new Error('CLOUDFLARE_API_TOKEN is required for AI text generation'); + } + + const resp = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/run/@cf/meta/llama-2-7b-chat-int8`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + messages: [ + { + role: 'user', + content: prompt, + }, + ], + }), + }, + ); + + if (!resp.ok) { + throw new Error( + `Cloudflare AI API error: ${resp.status} ${resp.statusText}`, + ); + } + + const { result } = await resp.json(); + + if (!result) { + throw new Error('No result returned from Cloudflare AI API'); + } + + return result.response; +} diff --git a/backend/services/automations/src/ai/propmts.ts b/backend/services/automations/src/ai/propmts.ts new file mode 100644 index 0000000000..a9a66f86ad --- /dev/null +++ b/backend/services/automations/src/ai/propmts.ts @@ -0,0 +1,106 @@ +export const getPropmtMessageTemplate = ( + documentContexts: string, + userQuery: string, +) => { + return ` +You are an enterprise-grade AI assistant with access to multiple documents. Your task is to provide comprehensive, accurate, and contextually relevant responses based on the available documentation. + +Available Documents: +${documentContexts} + +User Question: "${userQuery}" + +Instructions: +1. Analyze all relevant documents to find the most accurate information +2. If multiple documents contain relevant information, synthesize them coherently +3. If the question cannot be answered from the documents, clearly state this +4. Provide specific, actionable information when possible +5. Cite which document(s) your information comes from +6. If the documents contain conflicting information, acknowledge this +7. Structure your response in a clear, professional manner + +Please provide a comprehensive response based on the available documentation:`; +}; + +export const getTopicClassificationPrompt = ( + userQuery: string, + topics: Array<{ id: string; topicName: string; prompt: string }>, +) => { + const topicsList = topics + .map( + (topic, index) => + `${index + 1}. ID: ${topic.id}, Name: ${ + topic.topicName + }, Description: ${topic.prompt}`, + ) + .join('\n'); + + return `You are an enterprise-grade AI assistant that specializes in topic classification for workflow routing. Your task is to analyze the user's query against available topics to determine the most relevant topic for flow splitting. + +Available Topics: +${topicsList} + +User Query: "${userQuery}" + +Instructions: +1. Analyze the user query to understand their intent and the type of information they're seeking +2. Match the query against the available topics based on: + - Topic name relevance + - Topic description/prompt alignment + - User intent and context +3. Select the most relevant topic ID that best matches the user's query +4. Consider the semantic meaning and context of the query + +Your response should be in the following format: +TOPIC_ID: [selected topic ID] + +Please analyze and respond with only the topic ID:`; +}; + +export const getObjectGenerationPrompt = ( + userQuery: string, + objectFields: Array<{ + fieldName: string; + dataType: string; + prompt: string; + validation: string; + }>, +) => { + const fieldsList = objectFields + .map( + (field, index) => + `${index + 1}. Field: ${field.fieldName}, Type: ${ + field.dataType + }, Description: ${field.prompt}, Validation: ${field.validation}`, + ) + .join('\n'); + + return `You are an enterprise-grade AI assistant that specializes in structured data extraction and object generation. Your task is to analyze the user's query and extract relevant information to populate the specified object fields. + +Required Object Fields: +${fieldsList} + +User Query: "${userQuery}" + +Instructions: +1. Analyze the user query to extract relevant information for each field +2. For each field, determine the appropriate value based on: + - Field description and purpose + - Data type requirements + - Validation rules + - Information available in the user query +3. If information is not available in the query, set the field to null +4. Only extract information that is explicitly mentioned or clearly implied in the user query +5. Ensure all values match the specified data types +6. Follow the validation rules for each field + +Your response should be in the following JSON format: +{ + "fieldName1": "extracted_value", + "fieldName2": extracted_number, + "fieldName3": true/false, + "fieldName4": null +} + +Please analyze and respond with only the JSON object:`; +}; diff --git a/backend/services/automations/src/bullmq/actions/executePrevAction.ts b/backend/services/automations/src/bullmq/actionHandlerWorker/executePrevAction.ts similarity index 94% rename from backend/services/automations/src/bullmq/actions/executePrevAction.ts rename to backend/services/automations/src/bullmq/actionHandlerWorker/executePrevAction.ts index ec84f0fa65..7da8ccdb7b 100644 --- a/backend/services/automations/src/bullmq/actions/executePrevAction.ts +++ b/backend/services/automations/src/bullmq/actionHandlerWorker/executePrevAction.ts @@ -1,7 +1,7 @@ import type { Job } from 'bullmq'; -import { IJobData } from '@/bullmq'; +import { IJobData } from '@/bullmq/initMQWorkers'; import { IModels } from '@/connectionResolver'; -import { getActionsMap } from '@/utils/utils'; +import { getActionsMap } from '@/utils'; import { executeActions } from '@/executions/executeActions'; // Final job interfaces diff --git a/backend/services/automations/src/bullmq/actions/index.ts b/backend/services/automations/src/bullmq/actionHandlerWorker/index.ts similarity index 74% rename from backend/services/automations/src/bullmq/actions/index.ts rename to backend/services/automations/src/bullmq/actionHandlerWorker/index.ts index fccb758050..c943dca561 100644 --- a/backend/services/automations/src/bullmq/actions/index.ts +++ b/backend/services/automations/src/bullmq/actionHandlerWorker/index.ts @@ -1,7 +1,7 @@ import type { Job } from 'bullmq'; -import { executePrevActionWorker } from '@/bullmq/actions/executePrevAction'; -import { playWaitingActionWorker } from '@/bullmq/actions/playWait'; -import { setActionWaitHandler } from '@/bullmq/actions/setWait'; +import { executePrevActionWorker } from '@/bullmq/actionHandlerWorker/executePrevAction'; +import { playWaitingActionWorker } from '@/bullmq/actionHandlerWorker/playWait'; +import { setActionWaitHandler } from '@/bullmq/actionHandlerWorker/setWait'; import { generateModels, IModels } from '@/connectionResolver'; type ActionName = 'play' | 'wait' | 'executePrevAction'; diff --git a/backend/services/automations/src/bullmq/actions/playWait.ts b/backend/services/automations/src/bullmq/actionHandlerWorker/playWait.ts similarity index 95% rename from backend/services/automations/src/bullmq/actions/playWait.ts rename to backend/services/automations/src/bullmq/actionHandlerWorker/playWait.ts index a9fe59b05f..4829ba0238 100644 --- a/backend/services/automations/src/bullmq/actions/playWait.ts +++ b/backend/services/automations/src/bullmq/actionHandlerWorker/playWait.ts @@ -1,9 +1,9 @@ import type { Job } from 'bullmq'; import { AUTOMATION_EXECUTION_STATUS } from 'erxes-api-shared/core-modules'; -import { IJobData } from '@/bullmq'; +import { IJobData } from '@/bullmq/initMQWorkers'; import { IModels } from '@/connectionResolver'; import { debugInfo } from '@/debuuger'; -import { getActionsMap } from '@/utils/utils'; +import { getActionsMap } from '@/utils'; import { executeActions } from '@/executions/executeActions'; // Type for play wait job data diff --git a/backend/services/automations/src/bullmq/actions/setWait.ts b/backend/services/automations/src/bullmq/actionHandlerWorker/setWait.ts similarity index 80% rename from backend/services/automations/src/bullmq/actions/setWait.ts rename to backend/services/automations/src/bullmq/actionHandlerWorker/setWait.ts index acc6846bfe..141df3f06a 100644 --- a/backend/services/automations/src/bullmq/actions/setWait.ts +++ b/backend/services/automations/src/bullmq/actionHandlerWorker/setWait.ts @@ -1,8 +1,11 @@ +import { IModels } from '@/connectionResolver'; import type { Job } from 'bullmq'; +import { + AutomationExecutionSetWaitCondition, + EXECUTE_WAIT_TYPES, +} from 'erxes-api-shared/core-modules'; import { sendWorkerQueue } from 'erxes-api-shared/utils'; import moment from 'moment'; -import { IModels } from '@/connectionResolver'; -import { AutomationExecutionSetWaitCondition } from 'erxes-api-shared/core-modules'; export const setActionWaitHandler = async ( models: IModels, @@ -35,7 +38,7 @@ export const setExecutionWaitAction = async ( condition, } = data; - if (condition.type === 'delay') { + if (condition.type === EXECUTE_WAIT_TYPES.DELAY) { const { subdomain, startWaitingDate, waitFor, timeUnit } = condition; if (!subdomain) { @@ -77,7 +80,7 @@ export const setExecutionWaitAction = async ( return; } - if (condition.type === 'isInSegment') { + if (condition.type === EXECUTE_WAIT_TYPES.IS_IN_SEGMENT) { const { targetId, segmentId } = condition; await models.WaitingActions.create({ @@ -85,17 +88,18 @@ export const setExecutionWaitAction = async ( executionId, currentActionId, responseActionId, - conditionType: 'isInSegment', + conditionType: EXECUTE_WAIT_TYPES.IS_IN_SEGMENT, + conditionConfig: { - targetId, segmentId, + targetId, }, }); return; } - if (condition.type === 'checkObject') { + if (condition.type === EXECUTE_WAIT_TYPES.CHECK_OBJECT) { const { propertyName, expectedState, @@ -128,7 +132,7 @@ export const setExecutionWaitAction = async ( executionId, currentActionId, responseActionId, - conditionType: 'checkObject', + conditionType: EXECUTE_WAIT_TYPES.CHECK_OBJECT, conditionConfig: { propertyName, expectedState, @@ -141,4 +145,16 @@ export const setExecutionWaitAction = async ( return; } + + if (condition.type === EXECUTE_WAIT_TYPES.WEBHOOK) { + const { endpoint, secret, schema } = condition; + await models.WaitingActions.create({ + automationId, + executionId, + currentActionId, + responseActionId, + conditionType: EXECUTE_WAIT_TYPES.WEBHOOK, + conditionConfig: { endpoint, secret, schema }, + }); + } }; diff --git a/backend/services/automations/src/bullmq/actionWorker.ts b/backend/services/automations/src/bullmq/actionWorker.ts deleted file mode 100644 index fccb758050..0000000000 --- a/backend/services/automations/src/bullmq/actionWorker.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Job } from 'bullmq'; -import { executePrevActionWorker } from '@/bullmq/actions/executePrevAction'; -import { playWaitingActionWorker } from '@/bullmq/actions/playWait'; -import { setActionWaitHandler } from '@/bullmq/actions/setWait'; -import { generateModels, IModels } from '@/connectionResolver'; -type ActionName = 'play' | 'wait' | 'executePrevAction'; - -const actionHandlers: Record< - ActionName, - (models: IModels, job: Job) => Promise -> = { - play: playWaitingActionWorker, - wait: setActionWaitHandler, - executePrevAction: executePrevActionWorker, -}; - -export const actionHandlerWorker = async (job: Job) => { - const name = job.name as ActionName; - const { subdomain } = job.data || {}; - - const models = await generateModels(subdomain); - - const handler = actionHandlers[name]; - - if (!handler) { - throw new Error(`No handler found for job name: ${name}`); - } - - return handler(models, job); -}; diff --git a/backend/services/automations/src/bullmq/aiWorker.ts b/backend/services/automations/src/bullmq/aiWorker.ts new file mode 100644 index 0000000000..ebd55cd0da --- /dev/null +++ b/backend/services/automations/src/bullmq/aiWorker.ts @@ -0,0 +1,161 @@ +import { FileEmbeddingService } from '@/ai/fielEmbedding'; +import { generateAiAgentMessage } from '@/ai/generateAiAgentMessage'; +import { generateModels } from '@/connectionResolver'; +import { TAiAgentConfigForm } from '@/types/aiAgentAction'; +import { Job } from 'bullmq'; +import { AUTOMATION_EXECUTION_STATUS } from 'erxes-api-shared/core-modules'; +import { getEnv, sanitizeKey } from 'erxes-api-shared/utils'; + +// Worker for AI training +export const startAiTraining = async (job: Job) => { + const { data, subdomain } = job.data; + const { agentId } = data; + const models = await generateModels(subdomain); + const bucketName = getEnv({ name: 'R2_BUCKET_NAME' }); + + // Get AI agent with files + const agent = await models.AiAgents.findById({ _id: agentId }); + if (!agent) { + throw new Error('AI Agent not found'); + } + + const files = agent.files; + if (files.length === 0) { + throw new Error('No files found for training'); + } + + // Clear existing embeddings for this agent + await models.AiEmbeddings.deleteMany({ + fileId: { $in: files.map(({ id }) => id) }, + }); + + const fileEmbeddingService = new FileEmbeddingService(); + let processedFiles = 0; + + // Process each file + for (const { id, key, name } of files) { + try { + // Create embedding for the file + const fileEmbedding = await fileEmbeddingService.embedUploadedFile( + models, + sanitizeKey(key), + ); + + // Store in database + await models.AiEmbeddings.create({ + fileId: id, + fileName: name, + key, + bucket: bucketName, + fileContent: fileEmbedding.fileContent, + embedding: fileEmbedding.embedding, + embeddingModel: 'bge-large-en-v1.5', + dimensions: fileEmbedding.embedding.length, + }); + + processedFiles++; + } catch (error) { + console.error(`Failed to process file :`, error); + // Continue with other files + } + } + + return { + agentId, + totalFiles: files.length, + processedFiles, + status: processedFiles === files.length ? 'completed' : 'failed', + error: + processedFiles < files.length + ? 'Some files failed to process' + : undefined, + }; +}; + +// Worker for AI execution (automation actions) +export const executeAiAgent = async (job: Job) => { + const { data = {}, subdomain } = job.data; + const { aiAgentActionId, executionId, actionId, inputData, triggerData } = + data; + + const models = await generateModels(subdomain); + + const execution = await models.Executions.findOne({ + _id: executionId, + }).lean(); + if (!execution) { + throw new Error('Execution not found'); + } + const automation = await models.Automations.findOne({ + _id: execution.automationId, + status: AUTOMATION_EXECUTION_STATUS.ACTIVE, + }).lean(); + + if (!automation) { + throw new Error('Automation not found'); + } + + const action = (automation.actions || []).find(({ id }) => id === actionId); + if (!action) { + throw new Error('AI Agent Action not found'); + } + + const actionConfig = (action.config || {}) as TAiAgentConfigForm; + + const aiAgent = await models.AiAgents.findOne({ + _id: actionConfig.aiAgentId, + }); + if (!aiAgent) { + throw new Error('Ai Agent not found'); + } + + const userInput = inputData || triggerData?.text || ''; + + // Prepare data based on goal type + const jobData: any = { + agentId: aiAgent._id, + userInput, + agentConfig: aiAgent.config, + }; + + if (actionConfig.goalType === 'generateText') { + jobData.textPrompt = actionConfig.prompt; + } + + if (actionConfig.goalType === 'classifyTopic') { + jobData.topics = actionConfig.topics; + } + + if (actionConfig.goalType === 'generateObject') { + jobData.objectFields = actionConfig.objectFields; + } + + return { + executionId, + actionId, + aiAgentActionId, + jobData, + }; +}; + +export const aiMessageGenerationWorker = async (job: Job) => { + const { data, subdomain } = job.data; + const { agentId, question } = data; + const models = await generateModels(subdomain); + const message = await generateAiAgentMessage(models, question, agentId); + return message; +}; + +export const aiWorker = async (job: Job) => { + const name = job.name; + switch (name) { + case 'generateText': + return aiMessageGenerationWorker(job); + case 'executeAiAgent': + return executeAiAgent(job); + case 'trainAiAgent': + return startAiTraining(job); + default: + throw new Error(`Unknown job name: ${name}`); + } +}; diff --git a/backend/services/automations/src/bullmq/index.ts b/backend/services/automations/src/bullmq/initMQWorkers.ts similarity index 85% rename from backend/services/automations/src/bullmq/index.ts rename to backend/services/automations/src/bullmq/initMQWorkers.ts index b1c184a910..2a2eadde84 100644 --- a/backend/services/automations/src/bullmq/index.ts +++ b/backend/services/automations/src/bullmq/initMQWorkers.ts @@ -1,8 +1,9 @@ import type { Job } from 'bullmq'; import { createMQWorkerWithListeners } from 'erxes-api-shared/utils'; -import { actionHandlerWorker } from '@/bullmq/actionWorker'; +import { actionHandlerWorker } from '@/bullmq/actionHandlerWorker'; import { triggerHandlerWorker } from '@/bullmq/triggerWorker'; import { debugInfo } from '@/debuuger'; +import { aiWorker } from '@/bullmq/aiWorker'; type ICommonJobData = { subdomain: string; @@ -36,6 +37,7 @@ export const initMQWorkers = async (redis: any) => { await Promise.all([ generateMQWorker(redis, 'trigger', triggerHandlerWorker), generateMQWorker(redis, 'action', actionHandlerWorker), + generateMQWorker(redis, 'aiAgent', aiWorker), ]); debugInfo('All workers initialized'); diff --git a/backend/services/automations/src/bullmq/triggerWorker.ts b/backend/services/automations/src/bullmq/triggerWorker.ts index 01acbd027c..48273aefe4 100644 --- a/backend/services/automations/src/bullmq/triggerWorker.ts +++ b/backend/services/automations/src/bullmq/triggerWorker.ts @@ -1,5 +1,5 @@ import type { Job } from 'bullmq'; -import { IJobData } from '@/bullmq'; +import { IJobData } from '@/bullmq/initMQWorkers'; import { generateModels } from '@/connectionResolver'; import { debugError, debugInfo } from '@/debuuger'; import { checkIsWaitingAction } from '@/executions/checkIsWaitingActionTarget'; diff --git a/backend/services/automations/src/connectionResolver.ts b/backend/services/automations/src/connectionResolver.ts index 13221d790c..080e2344c9 100644 --- a/backend/services/automations/src/connectionResolver.ts +++ b/backend/services/automations/src/connectionResolver.ts @@ -3,6 +3,8 @@ import { Model, Connection } from 'mongoose'; import { IMainContext } from 'erxes-api-shared/core-types'; import { createGenerateModels } from 'erxes-api-shared/utils'; import { + AiAgentDocument, + aiAgentSchema, automationExecutionSchema, automationSchema, IAutomationDocument, @@ -12,11 +14,17 @@ import { IAutomationWaitingActionDocument, waitingActionsToExecuteSchema, } from '@/mongo/waitingActionsToExecute'; +import { + aiEmbeddingSchema, + IAiEmbeddingDocument, +} from 'erxes-api-shared/core-modules'; export interface IModels { Automations: Model; Executions: Model; WaitingActions: Model; + AiEmbeddings: Model; + AiAgents: Model; } export interface IContext extends IMainContext { @@ -42,6 +50,15 @@ export const loadClasses = (db: Connection, subdomain: string): IModels => { Model >('automations_waiting_actions_execute', waitingActionsToExecuteSchema); + models.AiEmbeddings = db.model< + IAiEmbeddingDocument, + Model + >('ai_embeddings', aiEmbeddingSchema); + models.AiAgents = db.model>( + 'automations_ai_agents', + aiAgentSchema, + ); + return models; }; @@ -49,5 +66,6 @@ export const generateModels = createGenerateModels(loadClasses, { ignoreModels: [ 'automations_executions', 'automations_waiting_actions_execute', + 'ai_embeddings', ], }); diff --git a/backend/services/automations/src/constants.ts b/backend/services/automations/src/constants.ts deleted file mode 100644 index 6500c9959a..0000000000 --- a/backend/services/automations/src/constants.ts +++ /dev/null @@ -1,86 +0,0 @@ -export const ACTIONS = { - WAIT: 'delay', - IF: 'if', - SET_PROPERTY: 'setProperty', - SEND_EMAIL: 'sendEmail', -}; - -export const EMAIL_RECIPIENTS_TYPES = [ - { - type: 'customMail', - name: 'customMails', - label: 'Custom Mails', - }, - { - type: 'attributionMail', - name: 'attributionMails', - label: 'Attribution Mails', - }, - { - type: 'segmentBased', - name: 'segmentBased', - label: 'Trigger Segment Based Mails', - }, - { - type: 'teamMember', - name: 'teamMemberIds', - label: 'Team Members', - }, - { - type: 'lead', - name: 'leadIds', - label: 'Leads', - }, - { - type: 'customer', - name: 'customerIds', - label: 'Customers', - }, - { - type: 'company', - name: 'companyIds', - label: 'Companies', - }, -]; - -export const UI_ACTIONS = [ - { - type: 'if', - icon: 'Sitemap', - label: 'Branches', - description: 'Create simple or if/then branches', - isAvailable: true, - }, - { - type: 'setProperty', - icon: 'Flask', - label: 'Manage properties', - description: - 'Update existing default or custom properties for Contacts, Companies, Cards, Conversations', - isAvailable: true, - }, - { - type: 'delay', - icon: 'Hourglass', - label: 'Delay', - description: - 'Delay the next action with a timeframe, a specific event or activity', - isAvailable: true, - }, - { - type: 'workflow', - icon: 'JumpRope', - label: 'Workflow', - description: - 'Enroll in another workflow, trigger outgoing webhook or write custom code', - isAvailable: false, - }, - { - type: 'sendEmail', - icon: 'MailFast', - label: 'Send Email', - description: 'Send Email', - emailRecipientsConst: EMAIL_RECIPIENTS_TYPES, - isAvailable: true, - }, -]; diff --git a/backend/services/automations/src/executions/actions/emailAction/createTransporter.ts b/backend/services/automations/src/executions/actions/emailAction/createTransporter.ts new file mode 100644 index 0000000000..942a1405bd --- /dev/null +++ b/backend/services/automations/src/executions/actions/emailAction/createTransporter.ts @@ -0,0 +1,46 @@ +import * as AWS from 'aws-sdk'; +import { getConfig } from 'erxes-api-shared/core-modules'; +import * as nodemailer from 'nodemailer'; + +export const createTransporter = async ({ ses }) => { + if (ses) { + const AWS_SES_ACCESS_KEY_ID = await getConfig('AWS_SES_ACCESS_KEY_ID'); + + const AWS_SES_SECRET_ACCESS_KEY = await getConfig( + 'AWS_SES_SECRET_ACCESS_KEY', + ); + const AWS_REGION = await getConfig('AWS_REGION'); + + AWS.config.update({ + region: AWS_REGION, + accessKeyId: AWS_SES_ACCESS_KEY_ID, + secretAccessKey: AWS_SES_SECRET_ACCESS_KEY, + }); + + return nodemailer.createTransport({ + SES: new AWS.SES({ apiVersion: '2010-12-01' }), + }); + } + + const MAIL_SERVICE = await getConfig('MAIL_SERVICE', ''); + const MAIL_PORT = await getConfig('MAIL_PORT', ''); + const MAIL_USER = await getConfig('MAIL_USER', ''); + const MAIL_PASS = await getConfig('MAIL_PASS', ''); + const MAIL_HOST = await getConfig('MAIL_HOST', ''); + + let auth; + + if (MAIL_USER && MAIL_PASS) { + auth = { + user: MAIL_USER, + pass: MAIL_PASS, + }; + } + + return nodemailer.createTransport({ + service: MAIL_SERVICE, + host: MAIL_HOST, + port: MAIL_PORT, + auth, + }); +}; diff --git a/backend/services/automations/src/executions/actions/emailAction/executeEmailAction.ts b/backend/services/automations/src/executions/actions/emailAction/executeEmailAction.ts new file mode 100644 index 0000000000..0d959e6f6c --- /dev/null +++ b/backend/services/automations/src/executions/actions/emailAction/executeEmailAction.ts @@ -0,0 +1,40 @@ +import { generateEmailPayload } from '@/executions/actions/emailAction/generateEmailPayload'; +import { sendEmails } from '@/executions/actions/emailAction/sendEmails'; +import { setActivityLog } from '@/executions/actions/emailAction/utils'; + +export const executeEmailAction = async ({ + subdomain, + target, + execution, + triggerType, + config, +}) => { + try { + const payload = await generateEmailPayload({ + subdomain, + triggerType, + target, + config, + execution, + }); + + if (!payload) { + return { error: 'Something went wrong fetching data' }; + } + + const response = await sendEmails({ + payload, + }); + + await setActivityLog({ + subdomain, + triggerType, + target, + response, + }); + + return { ...payload, response }; + } catch (err) { + return { error: err.message }; + } +}; diff --git a/backend/services/automations/src/executions/actions/emailAction/generateEmailPayload.ts b/backend/services/automations/src/executions/actions/emailAction/generateEmailPayload.ts new file mode 100644 index 0000000000..3a15f0e5d3 --- /dev/null +++ b/backend/services/automations/src/executions/actions/emailAction/generateEmailPayload.ts @@ -0,0 +1,112 @@ +import { splitType, TAutomationProducers } from 'erxes-api-shared/core-modules'; +import { + getEnv, + sendCoreModuleProducer, + sendTRPCMessage, +} from 'erxes-api-shared/utils'; +import { EmailResolver } from './generateReciepentEmailsByType'; +import { getRecipientEmails } from './generateRecipientEmails'; +import { replaceDocuments } from './replaceDocuments'; +import { filterOutSenderEmail, formatFromEmail } from './utils'; + +export const generateEmailPayload = async ({ + subdomain, + target, + execution, + triggerType, + config, +}) => { + const { templateId, fromUserId, fromEmailPlaceHolder, sender } = config; + const [pluginName, type] = splitType(triggerType); + const version = getEnv({ name: 'VERSION' }); + const DEFAULT_AWS_EMAIL = getEnv({ name: 'DEFAULT_AWS_EMAIL' }); + + const MAIL_SERVICE = getEnv({ name: 'MAIL_SERVICE' }); + + // const template = await sendCoreMessage({ + // subdomain, + // action: "emailTemplatesFindOne", + // data: { + // _id: templateId, + // }, + // isRPC: true, + // defaultValue: null, + // }); + const template = { content: 'Hello World' }; + + let fromUserEmail = version === 'saas' ? DEFAULT_AWS_EMAIL : ''; + + if (MAIL_SERVICE === 'custom') { + const { resolvePlaceholderEmails } = new EmailResolver({ + subdomain, + execution, + target, + }); + + const emails = await resolvePlaceholderEmails( + { pluginName, contentType: type }, + fromEmailPlaceHolder, + 'attributionMail', + ); + if (!emails?.length) { + throw new Error('Cannot find from user'); + } + fromUserEmail = emails[0]; + } else if (fromUserId) { + const fromUser = await sendTRPCMessage({ + pluginName: 'core', + module: 'users', + action: 'findOne', + method: 'query', + input: { _id: fromUserId }, + defaultValue: null, + }); + + fromUserEmail = fromUser?.email; + } + + let replacedContent = (template?.content || '').replace( + new RegExp(`{{\\s*${type}\\.\\s*(.*?)\\s*}}`, 'g'), + '{{ $1 }}', + ); + + replacedContent = await replaceDocuments(subdomain, replacedContent, target); + + const { subject, content = '' } = await sendCoreModuleProducer({ + moduleName: 'automations', + pluginName, + producerName: TAutomationProducers.REPLACE_PLACEHOLDERS, + input: { + execution, + target, + config: { + target, + config: { + subject: config.subject, + content: replacedContent, + }, + }, + }, + defaultValue: {}, + }); + + const [toEmails, ccEmails] = await getRecipientEmails({ + subdomain, + config, + triggerType, + target, + execution, + }); + + if (!toEmails?.length && ccEmails?.length) { + throw new Error('"Recieving emails not found"'); + } + + return { + title: subject, + fromEmail: formatFromEmail(sender, fromUserEmail), + toEmails: filterOutSenderEmail(toEmails, fromUserEmail), + ccEmails: filterOutSenderEmail(ccEmails, fromUserEmail), + customHtml: content.replace(/{{\s*([^}]+)\s*}}/g, '-'), + }; +}; diff --git a/backend/services/automations/src/executions/actions/emailAction/generateReciepentEmailsByType.ts b/backend/services/automations/src/executions/actions/emailAction/generateReciepentEmailsByType.ts new file mode 100644 index 0000000000..13f2a3d032 --- /dev/null +++ b/backend/services/automations/src/executions/actions/emailAction/generateReciepentEmailsByType.ts @@ -0,0 +1,131 @@ +import { + sendCoreModuleProducer, + sendTRPCMessage, +} from 'erxes-api-shared/utils'; +import { extractValidEmails } from './utils'; +import { TAutomationProducers } from 'erxes-api-shared/core-modules'; + +export class EmailResolver { + private subdomain: string; + private execution?: any; + private target?: any; + + constructor({ + subdomain, + execution, + target, + }: { + subdomain: string; + serviceName?: string; + contentType?: string; + execution?: any; + target?: any; + }) { + this.subdomain = subdomain; + this.execution = execution; + this.target = target; + } + + async resolveTeamMemberEmails(params: any) { + const users = await sendTRPCMessage({ + method: 'query', + pluginName: 'core', + module: 'users', + action: 'find', + input: { query: { ...params } }, + defaultValue: [], + }); + + return extractValidEmails(users, 'email'); + } + + async resolveSegmentEmails( + pluginName: string, + contentType: string, + ): Promise { + const { triggerConfig, targetId } = this.execution || {}; + + const contentTypeIds = await sendTRPCMessage({ + method: 'query', + pluginName: 'core', + module: 'segments', + action: 'fetchSegment', + input: { + segmentId: triggerConfig.contentId, + options: { + defaultMustSelector: [{ match: { _id: targetId } }], + }, + }, + defaultValue: [], + }); + + if (contentType === 'user') { + return this.resolveTeamMemberEmails({ _id: { $in: contentTypeIds } }); + } + + return await sendCoreModuleProducer({ + moduleName: 'automations', + pluginName, + producerName: TAutomationProducers.REPLACE_PLACEHOLDERS, + input: { + type: contentType!, + config: { + [`${contentType}Ids`]: contentTypeIds, + }, + }, + defaultValue: {}, + }); + } + + async resolvePlaceholderEmails( + { pluginName, contentType }: { pluginName: string; contentType: string }, + value: string, + key: string, + ): Promise { + let emails: string[] = []; + const matches = (value || '').match(/\{\{\s*([^}]+)\s*\}\}/g); + const attributes = + matches?.map((match) => match.replace(/\{\{\s*|\s*\}\}/g, '')) || []; + + const relatedValueProps: Record = {}; + + if (!attributes.length) return []; + + for (const attribute of attributes) { + if (attribute === 'triggerExecutors') { + const executorEmails = await this.resolveSegmentEmails( + pluginName, + contentType, + ); + emails = [...emails, ...executorEmails]; + } + + relatedValueProps[attribute] = { + key: 'email', + filter: { + key: 'registrationToken', + value: null, + }, + }; + + if (['customers', 'companies'].includes(attribute)) { + relatedValueProps[attribute] = { key: 'primaryEmail' }; + if (this.target) this.target[attribute] = null; + } + } + const replacedContent = await sendCoreModuleProducer({ + moduleName: 'automations', + pluginName, + producerName: TAutomationProducers.REPLACE_PLACEHOLDERS, + input: { + target: { ...(this.target || {}), type: contentType }, + config: { [key]: value }, + relatedValueProps, + }, + defaultValue: {}, + }); + + const generatedEmails = extractValidEmails(replacedContent[key]); + return [...emails, ...generatedEmails]; + } +} diff --git a/backend/services/automations/src/executions/actions/emailAction/generateRecipientEmails.ts b/backend/services/automations/src/executions/actions/emailAction/generateRecipientEmails.ts new file mode 100644 index 0000000000..2f93c88808 --- /dev/null +++ b/backend/services/automations/src/executions/actions/emailAction/generateRecipientEmails.ts @@ -0,0 +1,119 @@ +import { splitType, TAutomationProducers } from 'erxes-api-shared/core-modules'; +import { sendCoreModuleProducer } from 'erxes-api-shared/utils'; +import { EmailResolver } from './generateReciepentEmailsByType'; +import { getEmailRecipientTypes } from './utils'; + +type EmailsGeneratorType = EmailResolver; + +enum RECIEPENT_TYPES { + TEAM_MEMBER = 'teamMember', + ATTRIBUTION_MAILS = 'attributionMail', + CUSTOM_MAILS = 'customMail', +} + +export const getRecipientEmails = async ({ + subdomain, + config, + triggerType, + target, + execution, +}) => { + const reciepentTypes = await getEmailRecipientTypes(); + const emailResolver = new EmailResolver({ + subdomain, + execution, + target, + }); + + const reciepentTypeKeys = reciepentTypes.map((rT) => rT.name); + + const commonProps = { + subdomain, + triggerType, + reciepentTypeKeys, + reciepentTypes, + emailResolver, + }; + + const toEmails = [...(await collectEmails(config, commonProps))]; + const ccEmails = [...(await collectEmails(config?.cc || {}, commonProps))]; + + return [toEmails, ccEmails]; +}; + +const collectEmails = async ( + config: any, + { + subdomain, + triggerType, + reciepentTypeKeys, + reciepentTypes, + emailResolver, + }: { + subdomain: string; + triggerType: string; + reciepentTypes: { + type: string; + name: string; + label: string; + pluginName?: string; + }[]; + reciepentTypeKeys: string[]; + emailResolver: EmailsGeneratorType; + }, +) => { + let recipientEmails: string[] = []; + + for (const key of Object.keys(config)) { + if (reciepentTypeKeys.includes(key) && !!config[key]) { + const [pluginName, contentType] = splitType(triggerType); + + const { type, pluginName: reciepentTypePluginName } = + reciepentTypes.find((rT) => rT.name === key) || {}; + + if (type === RECIEPENT_TYPES.TEAM_MEMBER) { + const emails = await emailResolver.resolveTeamMemberEmails({ + _id: { $in: config[key] || [] }, + }); + + recipientEmails = [...recipientEmails, ...emails]; + continue; + } + + if (type === RECIEPENT_TYPES.ATTRIBUTION_MAILS) { + const emails = await emailResolver.resolvePlaceholderEmails( + { pluginName, contentType }, + config[key], + type, + ); + + recipientEmails = [...recipientEmails, ...emails]; + continue; + } + + if (type === RECIEPENT_TYPES.CUSTOM_MAILS) { + const emails = config[key] || []; + + recipientEmails = [...recipientEmails, ...emails]; + continue; + } + + if (!!reciepentTypePluginName) { + const emails = await sendCoreModuleProducer({ + moduleName: 'automations', + pluginName, + producerName: TAutomationProducers.GET_RECIPIENTS_EMAILS, + input: { + type, + config, + }, + defaultValue: {}, + }); + recipientEmails = [...recipientEmails, ...emails]; + continue; + } + } + } + + return recipientEmails; +}; diff --git a/backend/services/automations/src/executions/actions/emailAction/replaceDocuments.ts b/backend/services/automations/src/executions/actions/emailAction/replaceDocuments.ts new file mode 100644 index 0000000000..1137e8a996 --- /dev/null +++ b/backend/services/automations/src/executions/actions/emailAction/replaceDocuments.ts @@ -0,0 +1,36 @@ +// import { isEnabled } from "@erxes/api-utils/src/serviceDiscovery"; +// import { sendCommonMessage } from "../../messageBroker"; + +import { isEnabled } from 'erxes-api-shared/utils'; + +export const replaceDocuments = async (subdomain, content, target) => { + if (!isEnabled('documents')) { + return content; + } + + // Regular expression to match `documents.` within `{{ }}` + const documentIds = [ + ...content.matchAll(/\{\{\s*document\.([a-zA-Z0-9_]+)\s*\}\}/g), + ].map((match) => match[1]); + + if (!!documentIds?.length) { + for (const documentId of documentIds) { + // this action not avaible + // const response = await sendCommonMessage({ + // serviceName: "documents", + // subdomain, + // action: "printDocument", + // data: { + // ...target, + // _id: documentId, + // itemId: target._id, + // }, + // isRPC: true, + // defaultValue: "", + // }); + // content = content.replace(`{{ document.${documentId} }}`, response); + } + } + + return content; +}; diff --git a/backend/services/automations/src/executions/actions/emailAction/sendEmails.ts b/backend/services/automations/src/executions/actions/emailAction/sendEmails.ts new file mode 100644 index 0000000000..ce37bc24c1 --- /dev/null +++ b/backend/services/automations/src/executions/actions/emailAction/sendEmails.ts @@ -0,0 +1,115 @@ +import { getEnv } from 'erxes-api-shared/utils'; +import { createTransporter } from './createTransporter'; +import { getConfig } from 'erxes-api-shared/core-modules'; +import { debugError } from '@/debuuger'; + +export const sendEmails = async ({ + payload, +}: { + payload: { + title: string; + fromEmail: string; + toEmails: string[]; + ccEmails: string[]; + customHtml: string; + }; +}) => { + const { + toEmails = [], + ccEmails = [], + fromEmail, + title, + customHtml, + } = payload; + + const NODE_ENV = getEnv({ name: 'NODE_ENV' }); + + const DEFAULT_EMAIL_SERVICE = await getConfig('DEFAULT_EMAIL_SERVICE', 'SES'); + const COMPANY_EMAIL_FROM = await getConfig('COMPANY_EMAIL_FROM'); + const AWS_SES_CONFIG_SET = await getConfig('AWS_SES_CONFIG_SET'); + const AWS_SES_ACCESS_KEY_ID = await getConfig('AWS_SES_ACCESS_KEY_ID'); + const AWS_SES_SECRET_ACCESS_KEY = await getConfig( + 'AWS_SES_SECRET_ACCESS_KEY', + ); + + if (!fromEmail && !COMPANY_EMAIL_FROM) { + throw new Error('From Email is required'); + } + + if (NODE_ENV === 'test') { + throw new Error('Node environment is required'); + } + + let transporter; + + try { + transporter = await createTransporter({ + ses: DEFAULT_EMAIL_SERVICE === 'SES', + }); + } catch (e) { + debugError(e.message); + throw new Error(e.message); + } + + let response: any; + const mailOptions: any = { + from: fromEmail || COMPANY_EMAIL_FROM, + to: toEmails.join(', '), // Combine all to emails with commas + cc: ccEmails.length ? ccEmails.join(', ') : undefined, // Combine all cc emails with commas + subject: title, + html: customHtml, + }; + + let headers: { [key: string]: string } = {}; + + if (!!AWS_SES_ACCESS_KEY_ID?.length && !!AWS_SES_SECRET_ACCESS_KEY.length) { + // For multiple recipients, you might want to handle email deliveries differently + // Either create one delivery record for all recipients or handle separately + // const emailDelivery = await sendCoreMessage({ + // subdomain, + // action: "emailDeliveries.create", + // data: { + // kind: "transaction", + // to: toEmails.join(", "), // All recipients + // from: fromEmail, + // subject: title, + // body: customHtml, + // status: "pending", + // }, + // isRPC: true, + // }); + + headers = { + 'X-SES-CONFIGURATION-SET': AWS_SES_CONFIG_SET || 'erxes', + // EmailDeliveryId: emailDelivery && emailDelivery._id, + }; + } else { + headers['X-SES-CONFIGURATION-SET'] = 'erxes'; + } + + mailOptions.headers = headers; + + if (!mailOptions.from) { + throw new Error(`"From" email address is missing: ${mailOptions.from}`); + } + + try { + const info = await transporter.sendMail(mailOptions); + response = { + from: mailOptions.from, + messageId: info.messageId, + toEmails, // All to emails + ccEmails: ccEmails.length ? ccEmails : undefined, + }; + } catch (error) { + response = { + from: mailOptions.from, + toEmails, + ccEmails: mailOptions.cc, + error, + }; + debugError(error); + } + + return response; +}; diff --git a/backend/services/automations/src/executions/actions/emailAction/utils.ts b/backend/services/automations/src/executions/actions/emailAction/utils.ts new file mode 100644 index 0000000000..7f830b1bb9 --- /dev/null +++ b/backend/services/automations/src/executions/actions/emailAction/utils.ts @@ -0,0 +1,99 @@ +import { AUTOMATION_EMAIL_RECIPIENTS_TYPES } from 'erxes-api-shared/core-modules'; +import { getPlugin, getPlugins } from 'erxes-api-shared/utils'; + +export const getEmailRecipientTypes = async () => { + let reciepentTypes: Array<{ + type: string; + name: string; + label: string; + pluginName?: string; + }> = [...AUTOMATION_EMAIL_RECIPIENTS_TYPES]; + + const plugins = await getPlugins(); + + for (const pluginName of plugins) { + const plugin = await getPlugin(pluginName); + const meta = plugin.config?.meta || {}; + + if (meta?.automations?.constants?.emailRecipIentTypes) { + const { emailRecipIentTypes } = meta?.automations?.constants || {}; + + reciepentTypes = [ + ...reciepentTypes, + ...emailRecipIentTypes.map((eTR) => ({ ...eTR, pluginName })), + ]; + } + } + return reciepentTypes; +}; + +export const extractValidEmails = ( + entry: string | any[], + key?: string, +): string[] => { + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + + if (Array.isArray(entry)) { + if (key) { + entry = entry.map((item) => item?.[key]); + } + + return entry + .filter((value) => typeof value === 'string') + .map((value) => value.trim()) + .filter((value) => emailRegex.test(value)); + } + + if (typeof entry === 'string') { + return entry + .split(/[\s,;]+/) // split by space, comma, or semicolon + .map((value) => value.trim()) + .filter( + (value) => + value && + value.toLowerCase() !== 'null' && + value.toLowerCase() !== 'undefined' && + emailRegex.test(value), + ); + } + + return []; +}; + +export const formatFromEmail = (sender, fromUserEmail) => { + if (sender && fromUserEmail) { + return `${sender} <${fromUserEmail}>`; + } + + if (fromUserEmail) { + return fromUserEmail; + } + + return null; +}; + +export const setActivityLog = async ({ + subdomain, + triggerType, + target, + response, +}) => { + if (response?.messageId) { + // await putActivityLog(subdomain, { + // action: "putActivityLog", + // data: { + // contentType: triggerType, + // contentId: target._id, + // createdBy: "automation", + // action: "sendEmail", + // }, + // }); + } +}; + +export const filterOutSenderEmail = ( + emails: string[], + fromUserEmail: string, +) => { + return emails.filter((email) => fromUserEmail !== email); +}; diff --git a/backend/services/automations/src/executions/actions/executeAiAgentAction.ts b/backend/services/automations/src/executions/actions/executeAiAgentAction.ts new file mode 100644 index 0000000000..55b2a1004c --- /dev/null +++ b/backend/services/automations/src/executions/actions/executeAiAgentAction.ts @@ -0,0 +1,56 @@ +import { sendAutomationWorkerMessage } from '@/utils/sendAutomationWorkerMessage'; +import { + IAutomationAction, + IAutomationExecutionDocument, +} from 'erxes-api-shared/core-modules'; + +export const executeAiAgentAction = async ( + subdomain: string, + execution: IAutomationExecutionDocument, + action: IAutomationAction, +) => { + try { + const inputData = await getInputData( + execution, + action?.config.inputMapping, + ); + + return await sendAutomationWorkerMessage({ + queueName: 'aiAgent', + jobName: 'executeAiAgent', + subdomain, + data: { + aiAgentActionId: action.id, + executionId: execution._id, + actionId: action.id, + inputData, + triggerData: execution.target, + }, + }); + } catch (error) { + throw new Error(`AI Agent Action failed: ${error.message}`); + } +}; + +const getInputData = async ( + execution: IAutomationExecutionDocument, + inputMapping: any, +) => { + switch (inputMapping.source) { + case 'trigger': + return getNestedValue(execution.target, inputMapping.path); + case 'previousAction': + const prevAction = (execution.actions || []).find( + (a: any) => a.id === inputMapping.path, + ); + return prevAction?.result; + case 'custom': + return inputMapping.customValue; + default: + return ''; + } +}; + +const getNestedValue = (obj: any, path: string) => { + return path.split('.').reduce((current, key) => current?.[key], obj); +}; diff --git a/backend/services/automations/src/executions/actions/executeCreateAction.ts b/backend/services/automations/src/executions/actions/executeCreateAction.ts new file mode 100644 index 0000000000..f46b0a475a --- /dev/null +++ b/backend/services/automations/src/executions/actions/executeCreateAction.ts @@ -0,0 +1,54 @@ +import { setWaitActionResponse } from '@/executions/setWaitActionResponse'; +import { + IAutomationAction, + IAutomationExecutionDocument, + splitType, + TAutomationProducers, +} from 'erxes-api-shared/core-modules'; +import { sendCoreModuleProducer } from 'erxes-api-shared/utils'; + +type TCreateActionResponse = Promise<{ + shouldBreak: boolean; + actionResponse: any; +}>; + +export const executeCreateAction = async ( + subdomain: string, + execution: IAutomationExecutionDocument, + action: IAutomationAction, +): TCreateActionResponse => { + const [pluginName, moduleName, collectionType, actionType] = splitType( + action.type, + ); + + const actionResponse = await sendCoreModuleProducer({ + moduleName: 'automations', + pluginName, + producerName: TAutomationProducers.RECEIVE_ACTIONS, + input: { + moduleName, + actionType, + action, + execution, + collectionType, + }, + defaultValue: null, + }); + + const waitCondition = actionResponse?.waitCondition; + let shouldBreak = false; + + if (waitCondition) { + return await setWaitActionResponse( + subdomain, + execution, + action, + waitCondition, + ); + } + if (actionResponse.error) { + throw new Error(actionResponse.error); + } + + return { shouldBreak, actionResponse }; +}; diff --git a/backend/services/automations/src/executions/handleWait.ts b/backend/services/automations/src/executions/actions/executeDelayAction.ts similarity index 84% rename from backend/services/automations/src/executions/handleWait.ts rename to backend/services/automations/src/executions/actions/executeDelayAction.ts index f35ec5b1fe..89041b074e 100644 --- a/backend/services/automations/src/executions/handleWait.ts +++ b/backend/services/automations/src/executions/actions/executeDelayAction.ts @@ -1,15 +1,16 @@ +import { TDelayActionConfig } from '@/types'; import { AUTOMATION_EXECUTION_STATUS, - IAction, + IAutomationAction, IAutomationExecAction, IAutomationExecutionDocument, } from 'erxes-api-shared/core-modules'; import { sendWorkerQueue } from 'erxes-api-shared/utils'; -export const handleWaitAction = async ( +export const executeDelayAction = async ( subdomain: string, execution: IAutomationExecutionDocument, - action: IAction, + action: IAutomationAction, execAction: IAutomationExecAction, ) => { execution.waitingActionId = action.id; diff --git a/backend/services/automations/src/executions/handleifCondition.ts b/backend/services/automations/src/executions/actions/executeIfCondition.ts similarity index 64% rename from backend/services/automations/src/executions/handleifCondition.ts rename to backend/services/automations/src/executions/actions/executeIfCondition.ts index a3ccec2060..8049afe5e2 100644 --- a/backend/services/automations/src/executions/handleifCondition.ts +++ b/backend/services/automations/src/executions/actions/executeIfCondition.ts @@ -1,21 +1,29 @@ import { - IAction, - IActionsMap, + IAutomationAction, + IAutomationActionsMap, IAutomationExecAction, IAutomationExecutionDocument, } from 'erxes-api-shared/core-modules'; -import { isInSegment } from '@/utils/segments/utils'; +import { isInSegment } from '@/utils/isInSegment'; import { executeActions } from '@/executions/executeActions'; +import { TIfActionConfig } from '@/types'; -export const handleifAction = async ( +export const executeIfCondition = async ( subdomain: string, triggerType: string, execution: IAutomationExecutionDocument, - action: IAction, + action: IAutomationAction, execAction: IAutomationExecAction, - actionsMap: IActionsMap, + actionsMap: IAutomationActionsMap, ) => { let ifActionId: string; + if (!action.config) { + throw new Error( + `Execute If Condition failed: action config is missing for action ID "${ + action?.id || 'unknown' + }"`, + ); + } const isIn = await isInSegment(action.config.contentId, execution.targetId); if (isIn) { diff --git a/backend/services/automations/src/executions/handleSetProperty.ts b/backend/services/automations/src/executions/actions/executeSetPropertyAction.ts similarity index 58% rename from backend/services/automations/src/executions/handleSetProperty.ts rename to backend/services/automations/src/executions/actions/executeSetPropertyAction.ts index d31ff85263..938065b9eb 100644 --- a/backend/services/automations/src/executions/handleSetProperty.ts +++ b/backend/services/automations/src/executions/actions/executeSetPropertyAction.ts @@ -1,25 +1,25 @@ import { - IAction, + IAutomationAction, IAutomationExecutionDocument, splitType, + TAutomationProducers, } from 'erxes-api-shared/core-modules'; -import { sendWorkerMessage } from 'erxes-api-shared/utils'; +import { sendCoreModuleProducer } from 'erxes-api-shared/utils'; -export const handleSetPropertyAction = async ( +export const executeSetPropertyAction = async ( subdomain: string, - action: IAction, + action: IAutomationAction, triggerType: string, execution: IAutomationExecutionDocument, ) => { const { module } = action.config; const [pluginName, moduleName, collectionType] = splitType(module); - return await sendWorkerMessage({ - subdomain, + return await sendCoreModuleProducer({ + moduleName: 'automations', pluginName, - queueName: 'automations', - jobName: 'receiveActions', - data: { + producerName: TAutomationProducers.RECEIVE_ACTIONS, + input: { moduleName, triggerType, actionType: 'set-property', diff --git a/backend/services/automations/src/executions/actions/executeWaitEvent.ts b/backend/services/automations/src/executions/actions/executeWaitEvent.ts new file mode 100644 index 0000000000..999b641ab2 --- /dev/null +++ b/backend/services/automations/src/executions/actions/executeWaitEvent.ts @@ -0,0 +1,106 @@ +import { setExecutionWaitAction } from '@/bullmq/actionHandlerWorker/setWait'; +import { generateModels } from '@/connectionResolver'; +import { TAutomationWaitEventConfig } from '@/types'; +import { + EXECUTE_WAIT_TYPES, + IAutomationAction, + IAutomationExecAction, + IAutomationExecutionDocument, +} from 'erxes-api-shared/core-modules'; +const getLastActionExecution = ( + actions: IAutomationExecAction[], + actionId: string, +) => { + const actionExecutions: IAutomationExecAction[] = []; + for (const action of actions) { + if (action.actionId === actionId) { + actionExecutions.push(action); + } + } + + if (!actionExecutions?.length) { + return null; + } + return actionExecutions[actionExecutions.length - 1]; +}; + +export const executeWaitEvent = async ( + subdomain: string, + execution: IAutomationExecutionDocument, + action: IAutomationAction, +) => { + const models = await generateModels(subdomain); + const { + targetType, + segmentId, + targetTriggerId, + targetActionId, + webhookConfig, + } = action.config || {}; + + if (targetType === 'trigger' && targetTriggerId && segmentId) { + return await setExecutionWaitAction(models, { + executionId: execution._id, + currentActionId: action.id, + responseActionId: action.nextActionId, + automationId: execution.automationId, + condition: { + type: EXECUTE_WAIT_TYPES.IS_IN_SEGMENT, + targetId: execution.triggerId, + segmentId, + }, + }); + } + + if (targetType === 'action' && targetActionId && segmentId) { + const { actions = [] } = execution || {}; + + const actionExecution = getLastActionExecution(actions, targetActionId); + + if (!actionExecution || !actionExecution?.result) { + throw new Error('Action execution not found'); + } + + const { targetId } = actionExecution.result || {}; + if (!targetId) { + throw new Error( + 'Failed to set wait condition: targetId not found in action execution result', + ); + } + + return await setExecutionWaitAction(models, { + executionId: execution._id, + currentActionId: action.id, + responseActionId: action.nextActionId, + automationId: execution.automationId, + condition: { + type: EXECUTE_WAIT_TYPES.IS_IN_SEGMENT, + targetId, + segmentId, + }, + }); + } + + if (targetType === 'custom') { + if (!webhookConfig?.endpoint) { + throw new Error('Invalid webhook wait condition: endpoint not provided'); + } + + if (!webhookConfig?.security?.secret) { + throw new Error('Invalid webhook wait condition: secret not provided'); + } + + return await setExecutionWaitAction(models, { + executionId: execution._id, + currentActionId: action.id, + responseActionId: action.nextActionId, + automationId: execution.automationId, + condition: { + type: EXECUTE_WAIT_TYPES.WEBHOOK, + endpoint: webhookConfig?.endpoint, + secret: webhookConfig.security.secret, + schema: webhookConfig.schema, + }, + }); + } +}; diff --git a/backend/services/automations/src/executions/actions/webhook/incoming/bodyValidator.ts b/backend/services/automations/src/executions/actions/webhook/incoming/bodyValidator.ts new file mode 100644 index 0000000000..333b9e42dd --- /dev/null +++ b/backend/services/automations/src/executions/actions/webhook/incoming/bodyValidator.ts @@ -0,0 +1,94 @@ +type SchemaField = { + id: string; + name: string; + type: 'string' | 'number' | 'boolean' | 'object' | 'array'; + required?: boolean; + children?: SchemaField[]; // for object + arrayItemType?: 'string' | 'number' | 'boolean' | 'object'; + arrayItemSchema?: SchemaField[]; // for array of objects +}; + +export function validateAgainstSchema( + schema: SchemaField[], + body: any, + path: string = '', +): string[] { + const errors: string[] = []; + + for (const field of schema) { + const fieldPath = path ? `${path}.${field.name}` : field.name; + const value = body?.[field.name]; + + // Required check + if (field.required && (value === undefined || value === null)) { + errors.push(`${fieldPath} is required`); + continue; + } + + // Skip validation if value not provided + if (value === undefined || value === null) continue; + + switch (field.type) { + case 'string': + if (typeof value !== 'string') { + errors.push(`${fieldPath} must be a string`); + } + break; + + case 'number': + if (typeof value !== 'number') { + errors.push(`${fieldPath} must be a number`); + } + break; + + case 'boolean': + if (typeof value !== 'boolean') { + errors.push(`${fieldPath} must be a boolean`); + } + break; + + case 'object': + if (typeof value !== 'object' || Array.isArray(value)) { + errors.push(`${fieldPath} must be an object`); + } else if (field.children) { + errors.push( + ...validateAgainstSchema(field.children, value, fieldPath), + ); + } + break; + + case 'array': + if (!Array.isArray(value)) { + errors.push(`${fieldPath} must be an array`); + } else { + value.forEach((item, idx) => { + const itemPath = `${fieldPath}[${idx}]`; + + if (field.arrayItemType) { + if (field.arrayItemType === 'object') { + if (typeof item !== 'object' || Array.isArray(item)) { + errors.push(`${itemPath} must be an object`); + } else if (field.arrayItemSchema) { + errors.push( + ...validateAgainstSchema( + field.arrayItemSchema, + item, + itemPath, + ), + ); + } + } else if (typeof item !== field.arrayItemType) { + errors.push(`${itemPath} must be a ${field.arrayItemType}`); + } + } + }); + } + break; + + default: + errors.push(`${fieldPath} has unknown type ${field.type}`); + } + } + + return errors; +} diff --git a/backend/services/automations/src/executions/actions/webhook/incoming/incomingWebhook.ts b/backend/services/automations/src/executions/actions/webhook/incoming/incomingWebhook.ts new file mode 100644 index 0000000000..939e41c886 --- /dev/null +++ b/backend/services/automations/src/executions/actions/webhook/incoming/incomingWebhook.ts @@ -0,0 +1,57 @@ +import { incomingWebhookHandler } from '@/executions/actions/webhook/incoming/incomingWebhookHandler'; +import { incomingWebhookHealthHandler } from '@/executions/actions/webhook/incoming/incomingWebhookHealthHandler'; +import { + continueRateLimit, + webhookRateLimit, +} from '@/executions/actions/webhook/incoming/rateLimits'; +import { waitingWebhookExecutionHandler } from '@/executions/actions/webhook/incoming/waitingWebhookExecutionHandler'; +import express, { Router } from 'express'; +import helmet from 'helmet'; + +export const incomingWebhookRouter: Router = express.Router(); + +incomingWebhookRouter.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'none'"], + frameAncestors: ["'none'"], + }, + }, + hidePoweredBy: true, + hsts: { + maxAge: 31536000, + includeSubDomains: true, + preload: true, + }, + }), +); + +const rawBodyMiddleware = express.raw({ + type: '*/*', + limit: '1mb', // limit payload to reasonable size + verify: (req, _, buf: Buffer) => { + // Store raw body for signature verification + (req as any).rawBody = buf; + }, +}); +incomingWebhookRouter.all( + '/:id/*', + webhookRateLimit, + rawBodyMiddleware, + incomingWebhookHandler, +); + +// Health check endpoint for webhook route +incomingWebhookRouter.get( + '/:id/health', + rawBodyMiddleware, + incomingWebhookHealthHandler, +); + +incomingWebhookRouter.get( + '/executions/:executionId/continue/*', + rawBodyMiddleware, + continueRateLimit, + waitingWebhookExecutionHandler, +); diff --git a/backend/services/automations/src/executions/actions/webhook/incoming/incomingWebhookHandler.ts b/backend/services/automations/src/executions/actions/webhook/incoming/incomingWebhookHandler.ts new file mode 100644 index 0000000000..5235e544d3 --- /dev/null +++ b/backend/services/automations/src/executions/actions/webhook/incoming/incomingWebhookHandler.ts @@ -0,0 +1,202 @@ +import { generateModels } from '@/connectionResolver'; +import { validateAgainstSchema } from '@/executions/actions/webhook/incoming/bodyValidator'; +import { validateSecurity } from '@/executions/actions/webhook/incoming/utils'; +import { executeActions } from '@/executions/executeActions'; +import { getActionsMap } from '@/utils'; +import { + AUTOMATION_CORE_TRIGGER_TYPES, + AUTOMATION_EXECUTION_STATUS, +} from 'erxes-api-shared/core-modules'; +import { ILogDoc } from 'erxes-api-shared/core-types'; +import { getSubdomain, sendWorkerQueue } from 'erxes-api-shared/utils'; +import { Request, Response } from 'express'; + +export const incomingWebhookHandler = async (req: Request, res: Response) => { + const { id } = req.params; + const restPath = req.params[0] || ''; + + // Security headers for webhook responses + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('X-XSS-Protection', '1; mode=block'); + + try { + const subdomain = getSubdomain(req); + const models = await generateModels(subdomain); + + const endpoint = restPath.startsWith('/') ? restPath : `/${restPath}`; + + // Find automation with proper indexing consideration + const automation = await models.Automations.findOne({ + _id: id, + status: 'active', // Only active automations + triggers: { + $elemMatch: { + type: AUTOMATION_CORE_TRIGGER_TYPES.INCOMING_WEBHOOK, + 'config.endpoint': endpoint, + 'config.method': req.method, + }, + }, + }) + .select('_id triggers actions status') + .lean(); + + if (!automation) { + // Log security event (failed webhook attempt) + sendWorkerQueue('logs', 'put_log').add('put_log', { + subdomain, + source: 'webhook', + status: 'failed', + payload: { + type: 'webhook_security', + message: `Failed webhook attempt - Automation not found`, + webhookId: id, + endpoint, + method: req.method, + ip: req.ip, + userAgent: req.get('User-Agent'), + }, + } as ILogDoc); + + return res.status(404).json({ + success: false, + message: 'Webhook not found', + }); + } + + const trigger = automation.triggers.find( + ({ type, config }) => + type === AUTOMATION_CORE_TRIGGER_TYPES.INCOMING_WEBHOOK && + config?.endpoint === endpoint && + config?.method === req.method, + ); + + if (!trigger) { + return res.status(404).json({ + success: false, + message: 'Trigger not found', + }); + } + + // Enhanced security validation + try { + await validateSecurity(req, trigger.config); + } catch (securityError) { + // Log security violation + sendWorkerQueue('logs', 'put_log').add('put_log', { + subdomain, + source: 'webhook', + status: 'failed', + payload: { + type: 'webhook_security', + message: `Security validation failed: ${securityError.message}`, + webhookId: id, + endpoint, + method: req.method, + ip: req.ip, + userAgent: req.get('User-Agent'), + createdAt: new Date(), + }, + } as ILogDoc); + + return res.status(401).json({ + success: false, + message: 'Security validation failed', + }); + } + + // Schema validation + const { schema } = trigger.config || {}; + if (schema) { + const errors = validateAgainstSchema(schema, req.body); + if (errors.length > 0) { + return res.status(400).json({ + success: false, + message: 'Invalid payload', + errors, + }); + } + } + + // Create execution with security context + const execution = await models.Executions.create({ + automationId: automation._id, + triggerId: trigger.id, + triggerType: trigger.type, + triggerConfig: { + ...trigger.config, + security: undefined, // Remove secret from logs + }, + target: { + // Webhook data + body: req.body, + query: req.query, + headers: { + // Only log non-sensitive headers + 'user-agent': req.get('User-Agent'), + 'content-type': req.get('Content-Type'), + }, + method: req.method, + endpoint: endpoint, + + // Security context + security: { + ip: req.ip, + timestamp: new Date(), + validated: true, + }, + + // Identifiers + webhookId: id, + automationId: automation._id, + triggerId: trigger.id, + }, + status: AUTOMATION_EXECUTION_STATUS.ACTIVE, + description: `Secure webhook received from ${req.ip}`, + createdAt: new Date(), + }); + + // Execute actions asynchronously to prevent timeout issues + executeActions( + subdomain, + trigger.type, + execution, + await getActionsMap(automation.actions), + trigger.actionId, + ).catch(async (error) => { + // Log execution errors but don't expose to client + console.error('Webhook action execution failed:', error); + sendWorkerQueue('logs', 'put_log').add('put_log', { + subdomain, + source: 'webhook', + status: 'failed', + payload: { + type: 'webhook_execution_error', + message: 'Action execution failed', + data: { + executionId: execution._id, + automationId: automation._id, + error: error.message, + }, + createdAt: new Date(), + }, + } as ILogDoc); + }); + + // Success response (no sensitive data) + return res.json({ + success: true, + message: 'Webhook processed successfully', + executionId: execution._id, + receivedAt: new Date().toISOString(), + }); + } catch (error) { + console.error('Enterprise webhook error:', error); + + // Generic error message to avoid information leakage + return res.status(500).json({ + success: false, + message: 'Webhook processing failed', + }); + } +}; diff --git a/backend/services/automations/src/executions/actions/webhook/incoming/incomingWebhookHealthHandler.ts b/backend/services/automations/src/executions/actions/webhook/incoming/incomingWebhookHealthHandler.ts new file mode 100644 index 0000000000..f785a96c9e --- /dev/null +++ b/backend/services/automations/src/executions/actions/webhook/incoming/incomingWebhookHealthHandler.ts @@ -0,0 +1,44 @@ +import { generateModels } from '@/connectionResolver'; +import { getSubdomain } from 'erxes-api-shared/utils'; +import { Request, Response } from 'express'; + +export const incomingWebhookHealthHandler = async ( + req: Request, + res: Response, +) => { + const { id } = req.params; + + try { + const subdomain = getSubdomain(req); + const models = await generateModels(subdomain); + + const automation = await models.Automations.findOne({ + _id: id, + status: 'active', + }) + .select('_id status name') + .lean(); + + if (!automation) { + return res.status(404).json({ + success: false, + message: 'Webhook not found or inactive', + }); + } + + return res.json({ + success: true, + status: 'active', + automation: { + id: automation._id, + name: automation.name, + status: automation.status, + }, + }); + } catch (error) { + return res.status(500).json({ + success: false, + status: 'error', + }); + } +}; diff --git a/backend/services/automations/src/executions/actions/webhook/incoming/rateLimits.ts b/backend/services/automations/src/executions/actions/webhook/incoming/rateLimits.ts new file mode 100644 index 0000000000..0529acf8c2 --- /dev/null +++ b/backend/services/automations/src/executions/actions/webhook/incoming/rateLimits.ts @@ -0,0 +1,29 @@ +import rateLimit from 'express-rate-limit'; + +export const webhookRateLimit = rateLimit({ + windowMs: 1 * 60 * 1000, // 1 minute + max: 100, // limit each IP to 100 requests per windowMs + message: { + success: false, + message: 'Too many webhook requests, please try again later.', + }, + standardHeaders: true, + legacyHeaders: false, + skipSuccessfulRequests: false, + keyGenerator: (req) => { + return req.ip + req.params.id; // Rate limit per IP per webhook ID + }, +}); + +export const continueRateLimit = rateLimit({ + windowMs: 1 * 60 * 1000, // 1 minute + max: 20, // tune to your needs + message: { success: false, message: 'Too many requests, try later' }, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req) => { + // per-execution per-ip + const executionId = req.params.executionId || ''; + return `${req.ip}:${executionId}`; + }, +}); diff --git a/backend/services/automations/src/executions/actions/webhook/incoming/utils.ts b/backend/services/automations/src/executions/actions/webhook/incoming/utils.ts new file mode 100644 index 0000000000..8a92a57be3 --- /dev/null +++ b/backend/services/automations/src/executions/actions/webhook/incoming/utils.ts @@ -0,0 +1,194 @@ +import express from 'express'; + +import * as crypto from 'crypto'; +import { redis } from 'erxes-api-shared/utils'; +// IP whitelist validation +export const validateIPWhitelist = ( + req: express.Request, + allowedIPs: string[], +): boolean => { + if (!allowedIPs || allowedIPs.length === 0) return true; + + const clientIP = + req.ip || + (req.headers['x-forwarded-for'] as string)?.split(',')[0] || + req.socket.remoteAddress; + + if (!clientIP) { + throw new Error('Client not found'); + } + + return allowedIPs.some((ip) => { + if (ip.includes('/')) { + // CIDR notation + return isIPInCIDR(clientIP, ip); + } + return clientIP === ip; + }); +}; + +// CIDR check helper function +export const isIPInCIDR = (ip: string, cidr: string): boolean => { + // Implementation for CIDR check (you might want to use a library like 'ip-cidr') + // Simplified version for demonstration + const [range, prefix] = cidr.split('/'); + return ip === range; // In production, use proper CIDR validation +}; + +// // Constant-time comparison for security tokens +export const constantTimeCompare = (a: string, b: string): boolean => { + const aBuffer = Buffer.from(a); + const bBuffer = Buffer.from(b); + + if (aBuffer.length !== bBuffer.length) { + return false; + } + + // Convert Buffer to Uint8Array for compatibility + const aArray = new Uint8Array(aBuffer); + const bArray = new Uint8Array(bBuffer); + + return crypto.timingSafeEqual(aArray, bArray); +}; + +// Request timestamp validation (prevent replay attacks) +export const validateTimestamp = ( + req: express.Request, + maxAgeMs: number = 300000, +): boolean => { + const timestamp = req.headers['x-timestamp'] as string; + if (!timestamp) return false; + + const requestTime = parseInt(timestamp, 10); + const currentTime = Date.now(); + + return Math.abs(currentTime - requestTime) < maxAgeMs; // 5 minutes tolerance +}; + +// Enhanced security validation middleware +export const validateSecurity = async (req: express.Request, config: any) => { + const { security = {}, headers = [], queryParams = [] } = config; + + // 1. HMAC Signature Verification + if (security.secret) { + if (!verifyHMACSignature(req, security.secret)) { + throw new Error('Invalid signature'); + } + } + + // 2. IP Whitelist Validation + if (security.allowedIPs && security.allowedIPs.length > 0) { + if (!validateIPWhitelist(req, security.allowedIPs)) { + throw new Error('IP address not allowed'); + } + } + + // 3. Timestamp Validation (prevent replay attacks) + if (security.preventReplay) { + if (!validateTimestamp(req, security.maxAgeMs)) { + throw new Error('Request timestamp invalid or expired'); + } + } + + // 4. Header Validation (constant-time) + for (const h of headers) { + const headerValue = req.headers[h.key.toLowerCase()]; + if (!headerValue || !constantTimeCompare(String(headerValue), h.value)) { + throw new Error(`Invalid header: ${h.key}`); + } + } + + // 5. Query Parameter Validation (constant-time) + for (const qp of queryParams) { + const queryValue = req.query[qp.name]; + if (!queryValue || !constantTimeCompare(String(queryValue), qp.value)) { + throw new Error(`Invalid query parameter: ${qp.name}`); + } + } + + return true; +}; + +export function isTimestampValid(headerTs?: string, skewSeconds = 300) { + if (!headerTs) return false; + const tsNum = Number(headerTs) || Date.parse(headerTs); + if (Number.isNaN(tsNum)) return false; + const now = Date.now(); + const diff = Math.abs(now - tsNum); + return diff <= skewSeconds * 1000; +} +export async function trySetIdempotency(key: string, ttlSeconds = 60 * 60) { + // Correct order: SET key value EX ttl NX + const r = await redis.set(key, '1', 'EX', ttlSeconds, 'NX'); + return r === 'OK'; +} + +export function verifyHmac( + rawBody: Buffer, + secret: string, + headerSig?: string, +): boolean { + if (!headerSig) return false; + + // support formats like 'sha256=...' + const sig = Array.isArray(headerSig) ? headerSig[0] : headerSig; + const hmac = crypto.createHmac('sha256', secret); + + // Option 1: Convert Buffer to string with explicit encoding + const expected = `sha256=${hmac + .update(rawBody.toString('binary')) // or 'utf8' depending on your data + .digest('hex')}`; + + try { + // Convert both to Buffer with explicit encoding + const sigBuffer = new TextEncoder().encode(sig); + const expectedBuffer = new TextEncoder().encode(expected); + + // Ensure they're the same length (timingSafeEqual requires this) + if (sigBuffer.length !== expectedBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(sigBuffer, expectedBuffer); + } catch { + return false; + } +} + +export async function streamToBuffer(req: express.Request): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (c: Buffer) => chunks.push(c)); + req.on('end', () => resolve(Buffer.concat(chunks as any))); // Type assertion + req.on('error', (e) => reject(e)); + }); +} + +export const verifyHMACSignature = ( + req: express.Request, + secret: string, +): boolean => { + const signature = + req.headers['x-hub-signature-256'] || + req.headers['x-signature'] || + req.headers['signature']; + + if (!signature) return false; + + const expectedSignature = `sha256=${crypto + .createHmac('sha256', secret) + .update((req as any).rawBody || '') + .digest('hex')}`; + + const encoder = new TextEncoder(); + const signatureBuffer = encoder.encode( + Array.isArray(signature) ? signature[0] : signature, + ); + const expectedBuffer = encoder.encode(expectedSignature); + + if (signatureBuffer.length !== expectedBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(signatureBuffer, expectedBuffer); +}; diff --git a/backend/services/automations/src/executions/actions/webhook/incoming/waitingWebhookExecutionHandler.ts b/backend/services/automations/src/executions/actions/webhook/incoming/waitingWebhookExecutionHandler.ts new file mode 100644 index 0000000000..c56e3b4772 --- /dev/null +++ b/backend/services/automations/src/executions/actions/webhook/incoming/waitingWebhookExecutionHandler.ts @@ -0,0 +1,178 @@ +import { generateModels } from '@/connectionResolver'; +import { validateAgainstSchema } from '@/executions/actions/webhook/incoming/bodyValidator'; +import { + isTimestampValid, + streamToBuffer, + trySetIdempotency, + verifyHmac, +} from '@/executions/actions/webhook/incoming/utils'; +import { executeActions } from '@/executions/executeActions'; +import { getActionsMap } from '@/utils'; +import { + AUTOMATION_EXECUTION_STATUS, + AUTOMATION_STATUSES, + AutomationExecutionSetWaitCondition, + EXECUTE_WAIT_TYPES, +} from 'erxes-api-shared/core-modules'; +import { getSubdomain, sendWorkerQueue } from 'erxes-api-shared/utils'; +import { Request, Response } from 'express'; + +export const waitingWebhookExecutionHandler = async ( + req: Request, + res: Response, +) => { + const { executionId } = req.params; + const restPath = req.params[0] || ''; + + const subdomain = getSubdomain(req); + + // Basic header extraction + const headerSig = + req.get('x-webhook-signature') || req.get('x-hub-signature-256'); + const headerTs = req.get('x-webhook-timestamp'); + const idempotencyKey = (req.get('Idempotency-Key') || + req.get('X-Idempotency-Key')) as string | undefined; + + if (!isTimestampValid(headerTs, 300)) { + return res + .status(401) + .json({ success: false, message: 'Invalid or missing timestamp' }); + } + const idempotencyRedisKey = `webhook:idemp:${executionId}:${ + idempotencyKey || req.ip + }`; + + const idempOk = await trySetIdempotency(idempotencyRedisKey, 60 * 5); // 5min TTL + if (!idempOk) { + // Already processed/recently received + return res + .status(409) + .json({ success: false, message: 'Duplicate request (idempotency)' }); + } + + if (!idempOk) { + // Already processed/recently received + return res + .status(409) + .json({ success: false, message: 'Duplicate request (idempotency)' }); + } + // const secret = + // waitCondition?.endpointSecret || waitCondition?.security?.secret; + + // Quick checks + if (!executionId) { + return res + .status(400) + .json({ success: false, message: 'Missing execution id' }); + } + const models = await generateModels(subdomain); + + const execution = await models.Executions.findOne({ + _id: executionId, + }).lean(); + + if (!execution) { + return res + .status(404) + .json({ success: false, message: 'Execution not found' }); + } + + if (execution.status !== AUTOMATION_EXECUTION_STATUS.WAITING) { + // choose your own allowed statuses; fail safe + return res + .status(409) + .json({ success: false, message: 'Execution not in waiting state' }); + } + + const automation = await models.Automations.findOne({ + _id: execution.automationId, + status: AUTOMATION_STATUSES.ACTIVE, + }); + + if (!automation) { + return res + .status(400) + .json({ success: false, message: 'Missing automation' }); + } + + const endpoint = restPath.startsWith('/') ? restPath : `/${restPath}`; + + const waitingAction = await models.WaitingActions.findOne({ + conditionType: EXECUTE_WAIT_TYPES.WEBHOOK, + executionId: executionId, + automationId: execution.automationId, + 'conditionConfig.endpoint': endpoint, + }); + + if (!waitingAction) { + // choose your own allowed statuses; fail safe + return res.status(409).json({ + success: false, + message: 'Execution already processed or claimed', + }); + } + + const { secret, schema } = (waitingAction?.conditionConfig || {}) as Extract< + AutomationExecutionSetWaitCondition, + { type: EXECUTE_WAIT_TYPES.WEBHOOK } + >; + + if (secret) { + let raw: Buffer; + + if (req.body instanceof Buffer) { + raw = req.body; + } else { + const rawBuffer = await streamToBuffer(req); + raw = rawBuffer; + } + // If you used express.raw, req.body is the Buffer. If not, adapt accordingly. + const ok = verifyHmac(raw, secret, headerSig); + if (!ok) { + // log and return 401 + sendWorkerQueue('logs', 'put_log').add('put_log', { + subdomain, + source: 'webhook', + status: 'failed', + payload: { + type: 'webhook_security', + message: 'HMAC signature mismatch', + executionId, + ip: req.ip, + createdAt: new Date(), + }, + }); + return res + .status(401) + .json({ success: false, message: 'Invalid signature' }); + } + } + + if (schema) { + const errors = validateAgainstSchema(schema, req.body); + if (errors.length > 0) { + return res.status(400).json({ + success: false, + message: 'Invalid payload', + errors, + }); + } + } + + const { actions = [] } = automation; + + const action = actions.find( + ({ id }) => id === waitingAction.responseActionId, + ); + + executeActions( + subdomain, + execution.triggerType, + execution, + await getActionsMap(automation.actions || []), + action?.nextActionId, + ); + return res + .status(200) + .json({ success: true, message: 'Webhook accepted', executionId }); +}; diff --git a/backend/services/automations/src/executions/actions/webhook/incoming/webhookRoutes.ts b/backend/services/automations/src/executions/actions/webhook/incoming/webhookRoutes.ts new file mode 100644 index 0000000000..ab9401a98e --- /dev/null +++ b/backend/services/automations/src/executions/actions/webhook/incoming/webhookRoutes.ts @@ -0,0 +1,6 @@ +import { incomingWebhookRouter } from '@/executions/actions/webhook/incoming/incomingWebhook'; +import { Router } from 'express'; + +export const webhookRoutes: Router = Router(); + +webhookRoutes.use('/automation', incomingWebhookRouter); diff --git a/backend/services/automations/src/executions/actions/webhook/outgoing/outgoingWebhook.ts b/backend/services/automations/src/executions/actions/webhook/outgoing/outgoingWebhook.ts new file mode 100644 index 0000000000..2a5f412e4f --- /dev/null +++ b/backend/services/automations/src/executions/actions/webhook/outgoing/outgoingWebhook.ts @@ -0,0 +1,321 @@ +import { + OutgoingAuthConfig, + OutgoingHeaderItem, + OutgoingRetryOptions, + TOutgoinWebhookActionConfig, +} from '@/types'; +import { + IAutomationAction, + splitType, + TAutomationProducers, +} from 'erxes-api-shared/core-modules'; +import { sendCoreModuleProducer } from 'erxes-api-shared/utils'; +import * as https from 'https'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import jwt from 'jsonwebtoken'; + +type ReplaceResult = T; + +async function replacePlaceholders>( + subdomain: string, + triggerType: string, + target: any, + payload: T, +): Promise> { + const [pluginName] = splitType(triggerType); + const result = await sendCoreModuleProducer({ + moduleName: 'automations', + pluginName, + producerName: TAutomationProducers.REPLACE_PLACEHOLDERS, + input: { + target: { ...target, type: triggerType }, + config: payload, + }, + defaultValue: payload, + }); + return (result || payload) as ReplaceResult; +} + +function buildQuery(queryParams: { name: string; value: string }[]): string { + const urlParams = new URLSearchParams(); + for (const { name, value } of queryParams) { + if (!name) continue; + urlParams.append(name, value ?? ''); + } + const query = urlParams.toString(); + return query ? `?${query}` : ''; +} + +function applyBackoff( + baseDelay: number, + backoff: OutgoingRetryOptions['backoff'], + attempt: number, +): number { + if (backoff === 'exponential') return baseDelay * Math.pow(2, attempt - 1); + if (backoff === 'jitter') { + const exp = baseDelay * Math.pow(2, attempt - 1); + return Math.floor(Math.random() * exp); + } + return baseDelay; +} + +function toHeadersObject(items: OutgoingHeaderItem[]): Record { + const out: Record = {}; + for (const { key, value } of items) { + if (!key) continue; + out[key] = value ?? ''; + } + return out; +} + +function attachAuth( + headers: Record, + url: URL, + body: any, + auth?: OutgoingAuthConfig, +): { headers: Record; url: URL; body: any } { + if (!auth || auth.type === 'none') return { headers, url, body }; + + if (auth.type === 'basic') { + const token = Buffer.from(`${auth.username}:${auth.password}`).toString( + 'base64', + ); + headers['Authorization'] = `Basic ${token}`; + return { headers, url, body }; + } + + if (auth.type === 'bearer') { + headers['Authorization'] = `Bearer ${auth.token}`; + return { headers, url, body }; + } + + if (auth.type === 'jwt') { + const claims = auth.claims || {}; + const signOpts: any = {}; + if (auth.expiresIn) signOpts.expiresIn = auth.expiresIn; + if (auth.audience) signOpts.audience = auth.audience; + if (auth.issuer) signOpts.issuer = auth.issuer; + if (auth.header) signOpts.header = auth.header as any; + + const token = jwt.sign(claims, auth.secretKey, { + ...signOpts, + algorithm: (auth.algorithm as any) || 'HS256', + }); + + const placement = auth.placement || 'header'; + if (placement === 'header') { + headers['Authorization'] = `Bearer ${token}`; + } else if (placement === 'query') { + url.searchParams.set('access_token', token); + } else if (placement === 'body' && body && typeof body === 'object') { + body.access_token = token; + } + return { headers, url, body }; + } + + return { headers, url, body }; +} + +export async function executeOutgoingWebhook({ + subdomain, + triggerType, + target, + action, +}: { + subdomain: string; + triggerType: string; + target: any; + action: IAutomationAction; +}): Promise<{ + status: number; + ok: boolean; + headers: Record; + bodyText: string; +}> { + const { + method = 'POST', + url, + queryParams = [], + body = {}, + auth, + headers = [], + options = {}, + } = action?.config || {}; + + if (!url) throw new Error('Outgoing webhook url is required'); + + const timeoutMs = options.timeout ?? 10000; + const ignoreSSL = options.ignoreSSL ?? false; + const followRedirect = options.followRedirect ?? false; + const maxRedirects = options.maxRedirects ?? 5; + const retryOpts: OutgoingRetryOptions = { + attempts: options.retry?.attempts ?? 0, + delay: options.retry?.delay ?? 1000, + backoff: options.retry?.backoff ?? 'none', + }; + + const replacedHeaders = await replacePlaceholders< + Record + >( + subdomain, + triggerType, + target, + toHeadersObject( + (await replacePlaceholders>( + subdomain, + triggerType, + target, + headers.reduce((acc, h) => { + acc[h.key] = h.value; + return acc; + }, {} as Record), + )) as any, + ) as any, + ); + + const replacedBody = await replacePlaceholders( + subdomain, + triggerType, + target, + body || {}, + ); + + // Build URL with query params (evaluate expressions via worker) + const qpPairs = queryParams.map((q) => ({ name: q.name, value: q.value })); + const replacedQpObj = await replacePlaceholders( + subdomain, + triggerType, + target, + qpPairs.reduce((acc, cur) => { + acc[cur.name] = cur.value; + return acc; + }, {} as Record), + ); + const qpList: { name: string; value: string }[] = Object.entries( + replacedQpObj, + ).map(([k, v]) => ({ name: k, value: String(v ?? '') })); + + let currentUrl = new URL(url); + const query = buildQuery(qpList); + if (query) { + currentUrl = new URL(currentUrl.toString() + query); + } + + let headersObj: Record = {}; + for (const [k, v] of Object.entries(replacedHeaders || {})) { + headersObj[k] = String(v); + } + if (!headersObj['Content-Type'] && method !== 'GET' && method !== 'HEAD') { + headersObj['Content-Type'] = 'application/json'; + } + + let requestBody: any = replacedBody; + if ( + headersObj['Content-Type']?.includes('application/json') && + requestBody && + typeof requestBody !== 'string' + ) { + requestBody = JSON.stringify(requestBody); + } + + // Attach auth (basic/bearer/jwt) + const authApplied = attachAuth( + headersObj, + currentUrl, + requestBody, + auth as OutgoingAuthConfig | undefined, + ); + headersObj = authApplied.headers; + currentUrl = authApplied.url; + requestBody = authApplied.body; + + // Build agent (proxy / ignoreSSL) + let agent: any | undefined; + if (options.proxy?.host && options.proxy?.port) { + try { + const authStr = options.proxy.auth?.username + ? `${encodeURIComponent( + options.proxy.auth.username, + )}:${encodeURIComponent(options.proxy.auth.password || '')}@` + : ''; + const proxyUrl = `http://${authStr}${options.proxy.host}:${options.proxy.port}`; + agent = new HttpsProxyAgent(proxyUrl); + } catch { + // Fallback: ignore proxy if module is unavailable + } + } else if (ignoreSSL) { + agent = new https.Agent({ rejectUnauthorized: false }); + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + const doFetch = async (): Promise => { + const res = await fetch(currentUrl.toString(), { + method, + headers: headersObj, + body: method === 'GET' || method === 'HEAD' ? undefined : requestBody, + redirect: followRedirect ? 'follow' : 'manual', + // @ts-ignore + agent, + signal: controller.signal, + } as any); + + // Manual redirect handling when follow disabled but maxRedirects > 0 + let redirects = 0; + let response = res; + while ( + !followRedirect && + response.status >= 300 && + response.status < 400 && + redirects < maxRedirects + ) { + const loc = response.headers.get('location'); + if (!loc) break; + currentUrl = new URL(loc, currentUrl); + response = await fetch(currentUrl.toString(), { + method, + headers: headersObj, + body: method === 'GET' || method === 'HEAD' ? undefined : requestBody, + // @ts-ignore + agent, + signal: controller.signal, + }); + redirects += 1; + } + + return response; + }; + + let lastErr: any; + const attempts = Math.max(0, retryOpts.attempts || 0) + 1; + for (let i = 1; i <= attempts; i++) { + try { + const res = await doFetch(); + const bodyText = await res.text(); + const resultHeaders: Record = {}; + res.headers.forEach((v, k) => (resultHeaders[k] = v)); + clearTimeout(timer); + return { + status: res.status, + ok: res.ok, + headers: resultHeaders, + bodyText, + }; + } catch (e) { + lastErr = e; + if (i === attempts) break; + const delay = applyBackoff( + retryOpts.delay || 1000, + retryOpts.backoff || 'none', + i, + ); + await new Promise((r) => setTimeout(r, delay)); + } + } + + clearTimeout(timer); + throw lastErr instanceof Error + ? lastErr + : new Error('Outgoing webhook failed'); +} diff --git a/backend/services/automations/src/executions/calculateExecutions.ts b/backend/services/automations/src/executions/calculateExecutions.ts index 760164bf1a..3855d4b202 100644 --- a/backend/services/automations/src/executions/calculateExecutions.ts +++ b/backend/services/automations/src/executions/calculateExecutions.ts @@ -1,12 +1,67 @@ +import { IModels } from '@/connectionResolver'; +import { isDiffValue } from '@/utils'; +import { isInSegment } from '@/utils/isInSegment'; import { AUTOMATION_EXECUTION_STATUS, IAutomationExecutionDocument, - ITrigger, + IAutomationTrigger, splitType, + TAutomationProducers, } from 'erxes-api-shared/core-modules'; -import { sendWorkerMessage } from 'erxes-api-shared/utils'; -import { IModels } from '@/connectionResolver'; -import { isInSegment } from '@/utils/segments/utils'; +import { sendCoreModuleProducer } from 'erxes-api-shared/utils'; + +const checkIsValidCustomTigger = async ( + type: string, + subdomain: string, + automationId: string, + trigger: IAutomationTrigger, + target: any, + config: any, +) => { + const [pluginName, moduleName, collectionType] = splitType(type); + + return await sendCoreModuleProducer({ + moduleName: 'automations', + pluginName, + producerName: TAutomationProducers.CHECK_CUSTOM_TRIGGER, + input: { + moduleName, + collectionType, + automationId, + trigger, + target, + config, + }, + defaultValue: false, + }); +}; + +const checkValidTrigger = async ( + trigger: IAutomationTrigger, + target: any, + subdomain: string, + automationId: string, +) => { + const { type = '', config, isCustom } = trigger; + const { contentId } = config || {}; + if (!!isCustom) { + const isValidCustomTigger = await checkIsValidCustomTigger( + type, + subdomain, + automationId, + trigger, + target, + config, + ); + if (!isValidCustomTigger) { + return false; + } + } else if (!(await isInSegment(contentId, target._id))) { + return false; + } + + return true; +}; export const calculateExecution = async ({ models, @@ -18,37 +73,20 @@ export const calculateExecution = async ({ models: IModels; subdomain: string; automationId: string; - trigger: ITrigger; + trigger: IAutomationTrigger; target: any; }): Promise => { - const { id, type, config, isCustom } = trigger; - const { reEnrollment, reEnrollmentRules, contentId } = config || {}; + const { id, type = '', config } = trigger; + const { reEnrollment, reEnrollmentRules } = config || {}; try { - if (!!isCustom) { - const [pluginName, moduleName, collectionType] = splitType( - trigger?.type || '', - ); - - const isValid = await sendWorkerMessage({ - subdomain, - pluginName, - queueName: 'automations', - jobName: 'checkCustomTrigger', - data: { - moduleName, - collectionType, - automationId, - trigger, - target, - config, - }, - defaultValue: false, - }); - if (!isValid) { - return; - } - } else if (!(await isInSegment(contentId, target._id))) { + const isValidTrigger = await checkValidTrigger( + trigger, + target, + subdomain, + automationId, + ); + if (!isValidTrigger) { return; } } catch (e) { @@ -66,7 +104,7 @@ export const calculateExecution = async ({ return; } - const executions = await models.Executions.find({ + const latestExecution = await models.Executions.findOne({ automationId, triggerId: id, targetId: target._id, @@ -76,28 +114,24 @@ export const calculateExecution = async ({ .limit(1) .lean(); - const latestExecution: IAutomationExecutionDocument | null = executions.length - ? executions[0] - : null; - - // if (latestExecution) { - // if (!reEnrollment || !reEnrollmentRules.length) { - // return; - // } + if (latestExecution) { + if (!reEnrollment || !reEnrollmentRules.length) { + return; + } - // let isChanged = false; + let isChanged = false; - // for (const reEnrollmentRule of reEnrollmentRules) { - // if (isDiffValue(latestExecution.target, target, reEnrollmentRule)) { - // isChanged = true; - // break; - // } - // } + for (const reEnrollmentRule of reEnrollmentRules) { + if (isDiffValue(latestExecution.target, target, reEnrollmentRule)) { + isChanged = true; + break; + } + } - // if (!isChanged) { - // return; - // } - // } + if (!isChanged) { + return; + } + } return models.Executions.create({ automationId, diff --git a/backend/services/automations/src/executions/checkIsWaitingActionTarget.ts b/backend/services/automations/src/executions/checkIsWaitingActionTarget.ts index 1fdf5dd972..41bb5040b6 100644 --- a/backend/services/automations/src/executions/checkIsWaitingActionTarget.ts +++ b/backend/services/automations/src/executions/checkIsWaitingActionTarget.ts @@ -1,5 +1,6 @@ import { IModels } from '@/connectionResolver'; import { IAutomationWaitingActionDocument } from '@/mongo/waitingActionsToExecute'; +import { EXECUTE_WAIT_TYPES } from 'erxes-api-shared/core-modules'; function accessNestedObject(obj: any, keys: string[]) { return keys.reduce((acc, key) => acc && acc[key], obj) || ''; @@ -10,40 +11,42 @@ const handleCheckObjectCondition = async ( waitingAction: IAutomationWaitingActionDocument, target: any, ) => { - const { - expectedState = {}, - expectedStateConjunction = 'every', - propertyName, - shouldCheckOptionalConnect = false, - } = waitingAction.conditionConfig || {}; + if (waitingAction.conditionType === EXECUTE_WAIT_TYPES.CHECK_OBJECT) { + const { + expectedState = {}, + expectedStateConjunction = 'every', + propertyName, + shouldCheckOptionalConnect = false, + } = waitingAction.conditionConfig || {}; - const isMatch = Object.keys(expectedState)[ - expectedStateConjunction === 'some' ? 'some' : 'every' - ]((key) => target[key] === expectedState[key]); + const isMatch = Object.keys(expectedState)[ + expectedStateConjunction === 'some' ? 'some' : 'every' + ]((key) => target[key] === expectedState[key]); - if (!shouldCheckOptionalConnect) { - return isMatch ? waitingAction : null; - } - const valueToCheck = propertyName - ? accessNestedObject(target, propertyName.split('.')) - : undefined; + if (!shouldCheckOptionalConnect) { + return isMatch ? waitingAction : null; + } + const valueToCheck = propertyName + ? accessNestedObject(target, propertyName.split('.')) + : undefined; - const automation = await models.Automations.findOne({ - _id: waitingAction.automationId, - }); + const automation = await models.Automations.findOne({ + _id: waitingAction.automationId, + }); - for (const action of automation?.actions || []) { - const connects = action.config?.optionalConnects || []; + for (const action of automation?.actions || []) { + const connects = action.config?.optionalConnects || []; - const optionalConnect = connects.find( - ({ optionalConnectId }) => optionalConnectId === valueToCheck, - ); + const optionalConnect = connects.find( + ({ optionalConnectId }) => optionalConnectId === valueToCheck, + ); - if (optionalConnect && optionalConnect?.actionId) { - if (waitingAction.responseActionId !== optionalConnect.actionId) { - waitingAction.responseActionId = optionalConnect.actionId; + if (optionalConnect && optionalConnect?.actionId) { + if (waitingAction.responseActionId !== optionalConnect.actionId) { + waitingAction.responseActionId = optionalConnect.actionId; + } + return waitingAction; } - return waitingAction; } } @@ -59,9 +62,12 @@ export const checkIsWaitingAction = async ( for (const target of targets) { const waitingAction = await models.WaitingActions.findOne({ $or: [ - { conditionType: 'isInSegment', targetId: target._id }, { - conditionType: 'checkObject', + conditionType: EXECUTE_WAIT_TYPES.IS_IN_SEGMENT, + 'conditionConfig.targetId': target._id, + }, + { + conditionType: EXECUTE_WAIT_TYPES.CHECK_OBJECT, 'conditionConfig.contentType': { $regex: `^${type}\\..*` }, ...(executionId ? { executionId } : {}), }, @@ -74,7 +80,8 @@ export const checkIsWaitingAction = async ( const { conditionType } = waitingAction; - if (conditionType === 'checkObject') { + if (conditionType === EXECUTE_WAIT_TYPES.CHECK_OBJECT) { + waitingAction.conditionConfig; return await handleCheckObjectCondition(models, waitingAction, target); } diff --git a/backend/services/automations/src/executions/executeActions.ts b/backend/services/automations/src/executions/executeActions.ts index 4098d9a9ca..f98e637016 100644 --- a/backend/services/automations/src/executions/executeActions.ts +++ b/backend/services/automations/src/executions/executeActions.ts @@ -1,21 +1,22 @@ +import { executeCoreActions } from '@/executions/executeCoreActions'; +import { executeCreateAction } from '@/executions/actions/executeCreateAction'; +import { handleExecutionActionResponse } from '@/executions/handleExecutionActionResponse'; +import { handleExecutionError } from '@/executions/handleExecutionError'; import { + AUTOMATION_CORE_ACTIONS, AUTOMATION_EXECUTION_STATUS, - IActionsMap, + IAutomationActionsMap, IAutomationExecAction, IAutomationExecutionDocument, + splitType, } from 'erxes-api-shared/core-modules'; -import { ACTIONS } from '@/constants'; -import { handleCreateAction } from '@/executions/handleCreateAction'; -import { handleifAction } from '@/executions/handleifCondition'; -import { handleSetPropertyAction } from '@/executions/handleSetProperty'; -import { handleWaitAction } from '@/executions/handleWait'; -import { handleEmail } from '@/utils/actions/email'; +import { getPlugins } from 'erxes-api-shared/utils'; export const executeActions = async ( subdomain: string, triggerType: string, execution: IAutomationExecutionDocument, - actionsMap: IActionsMap, + actionsMap: IAutomationActionsMap, currentActionId?: string, ): Promise => { if (!currentActionId) { @@ -43,70 +44,56 @@ export const executeActions = async ( }; let actionResponse: any = null; + const actionType = action.type; try { - if (action.type === ACTIONS.WAIT) { - await handleWaitAction(subdomain, execution, action, execAction); - return 'paused'; - } - - if (action.type === ACTIONS.IF) { - return handleifAction( - subdomain, + if ( + Object.values(AUTOMATION_CORE_ACTIONS).find( + (value) => actionType === value, + ) + ) { + const coreActionResponse = await executeCoreActions( triggerType, + actionType, + subdomain, execution, action, execAction, actionsMap, ); - } - if (action.type === ACTIONS.SET_PROPERTY) { - actionResponse = await handleSetPropertyAction( - subdomain, - action, - triggerType, - execution, - ); - } + if (coreActionResponse.shouldBreak) { + return; + } + actionResponse = coreActionResponse.actionResponse; + } else { + const [serviceName, _module, _collection, method] = splitType(actionType); + const isRemoteAction = (await getPlugins()).includes(serviceName); - if (action.type === ACTIONS.SEND_EMAIL) { - // try { - actionResponse = await handleEmail({ - subdomain, - target: execution.target, - triggerType, - config: action.config, - execution, - }); - // } catch (err) { - // actionResponse = err.message; - // } - } + if (isRemoteAction) { + throw new Error('Plugin not enabled'); + } - if (action.type.includes('create')) { - actionResponse = await handleCreateAction( - subdomain, - execution, - action, - actionsMap, - ); - if (actionResponse === 'paused') { - return 'paused'; + if (isRemoteAction) { + if (method === 'create') { + const createActionResponse = await executeCreateAction( + subdomain, + execution, + action, + ); + if (createActionResponse.shouldBreak) { + return 'paused'; + } + actionResponse = createActionResponse.actionResponse; + } } } } catch (e) { - execAction.result = { error: e.message, result: e.result }; - execution.actions = [...(execution.actions || []), execAction]; - execution.status = AUTOMATION_EXECUTION_STATUS.ERROR; - execution.description = `An error occurred while working action: ${action.type}`; - await execution.save(); + await handleExecutionError(e, actionType, execution, execAction); return; } - execAction.result = actionResponse; - execution.actions = [...(execution.actions || []), execAction]; - execution = await execution.save(); + await handleExecutionActionResponse(actionResponse, execution, execAction); return executeActions( subdomain, diff --git a/backend/services/automations/src/executions/executeCoreActions.ts b/backend/services/automations/src/executions/executeCoreActions.ts new file mode 100644 index 0000000000..7a50245ad3 --- /dev/null +++ b/backend/services/automations/src/executions/executeCoreActions.ts @@ -0,0 +1,95 @@ +import { executeIfCondition } from '@/executions/actions/executeIfCondition'; +import { executeSetPropertyAction } from '@/executions/actions/executeSetPropertyAction'; +import { executeDelayAction } from '@/executions/actions/executeDelayAction'; +import { + AUTOMATION_CORE_ACTIONS, + IAutomationAction, + IAutomationActionsMap, + IAutomationExecAction, + IAutomationExecutionDocument, +} from 'erxes-api-shared/core-modules'; +import { executeEmailAction } from '@/executions/actions/emailAction/executeEmailAction'; +import { executeOutgoingWebhook } from '@/executions/actions/webhook/outgoing/outgoingWebhook'; +import { executeWaitEvent } from '@/executions/actions/executeWaitEvent'; +import { executeFindObjectAction } from '@/executions/executeFindObjectAction'; + +type TCoreActionResponse = Promise<{ + shouldBreak: boolean; + actionResponse?: any; +}>; + +export const executeCoreActions = async ( + triggerType: string, + actionType: string, + subdomain: string, + execution: IAutomationExecutionDocument, + action: IAutomationAction, + execAction: IAutomationExecAction, + actionsMap: IAutomationActionsMap, +): TCoreActionResponse => { + let shouldBreak = false; + + let actionResponse: any = null; + + if (actionType === AUTOMATION_CORE_ACTIONS.DELAY) { + await executeDelayAction(subdomain, execution, action, execAction); + return { actionResponse, shouldBreak: true }; + } + + if (actionType === AUTOMATION_CORE_ACTIONS.IF) { + executeIfCondition( + subdomain, + triggerType, + execution, + action, + execAction, + actionsMap, + ); + return { actionResponse, shouldBreak: true }; + } + + if (actionType === AUTOMATION_CORE_ACTIONS.WAIT_EVENT) { + executeWaitEvent(subdomain, execution, action); + return { actionResponse, shouldBreak: true }; + } + + if (actionType === AUTOMATION_CORE_ACTIONS.FIND_OBJECT) { + await executeFindObjectAction( + subdomain, + execution, + action, + execAction, + actionsMap, + ); + return { actionResponse, shouldBreak: true }; + } + + if (actionType === AUTOMATION_CORE_ACTIONS.SET_PROPERTY) { + actionResponse = await executeSetPropertyAction( + subdomain, + action, + triggerType, + execution, + ); + } + + if (actionType === AUTOMATION_CORE_ACTIONS.SEND_EMAIL) { + actionResponse = await executeEmailAction({ + subdomain, + target: execution.target, + triggerType, + config: action.config, + execution, + }); + } + + if (actionType === AUTOMATION_CORE_ACTIONS.OUTGOING_WEBHOOK) { + actionResponse = await executeOutgoingWebhook({ + subdomain, + triggerType, + target: execution.target, + action, + }); + } + return { actionResponse, shouldBreak }; +}; diff --git a/backend/services/automations/src/executions/executeFindObjectAction.ts b/backend/services/automations/src/executions/executeFindObjectAction.ts new file mode 100644 index 0000000000..6b6e32942e --- /dev/null +++ b/backend/services/automations/src/executions/executeFindObjectAction.ts @@ -0,0 +1,45 @@ +import { executeActions } from '@/executions/executeActions'; +import { + IAutomationAction, + IAutomationActionsMap, + IAutomationExecAction, + IAutomationExecutionDocument, + splitType, +} from 'erxes-api-shared/core-modules'; +import { sendTRPCMessage } from 'erxes-api-shared/utils'; + +export const executeFindObjectAction = async ( + subdomain: string, + execution: IAutomationExecutionDocument, + action: IAutomationAction, + execAction: IAutomationExecAction, + + actionsMap: IAutomationActionsMap, +) => { + const { propertyType, propertyField, propertyValue, exists, notExists } = + action.config; + const [pluginName, moduleName] = splitType(propertyType); + + const object = await sendTRPCMessage({ + pluginName, + module: moduleName, + action: 'findOne', + input: { + [propertyField]: propertyValue, + }, + defaultValue: null, + }); + + const actionId = exists ? action.id : notExists ? action.id : undefined; + execAction.nextActionId = actionId; + execAction.result = { object, isExists: !!object }; + execution.actions = [...(execution.actions || []), execAction]; + execution = await execution.save(); + return executeActions( + subdomain, + execution.triggerType, + execution, + actionsMap, + actionId, + ); +}; diff --git a/backend/services/automations/src/executions/executeWaitingAction.ts b/backend/services/automations/src/executions/executeWaitingAction.ts index c98490f485..6a3e7533d3 100644 --- a/backend/services/automations/src/executions/executeWaitingAction.ts +++ b/backend/services/automations/src/executions/executeWaitingAction.ts @@ -3,7 +3,7 @@ import { IModels } from '@/connectionResolver'; import { debugError } from '@/debuuger'; import { executeActions } from '@/executions/executeActions'; import { IAutomationWaitingActionDocument } from '@/mongo/waitingActionsToExecute'; -import { getActionsMap } from '@/utils/utils'; +import { getActionsMap } from '@/utils'; export const executeWaitingAction = async ( subdomain: string, diff --git a/backend/services/automations/src/executions/handleCreateAction.ts b/backend/services/automations/src/executions/handleCreateAction.ts deleted file mode 100644 index 462cf8e602..0000000000 --- a/backend/services/automations/src/executions/handleCreateAction.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { - IAction, - IActionsMap, - IAutomationExecutionDocument, - splitType, -} from 'erxes-api-shared/core-modules'; -import { sendWorkerMessage } from 'erxes-api-shared/utils'; -import { setExecutionWaitAction } from '@/bullmq/actions/setWait'; -import { generateModels } from '@/connectionResolver'; - -export const handleCreateAction = async ( - subdomain: string, - execution: IAutomationExecutionDocument, - action: IAction, - actionsMap: IActionsMap, -) => { - const [pluginName, moduleName, collectionType, actionType] = splitType( - action.type, - ); - - const actionResponse = await sendWorkerMessage({ - subdomain, - pluginName, - queueName: 'automations', - jobName: 'receiveActions', - data: { - moduleName, - actionType, - action, - execution, - collectionType, - }, - }); - const nextAction = action?.nextActionId - ? actionsMap[action?.nextActionId] - : null; - - const waitCondition = actionResponse?.waitCondition; - - if (waitCondition) { - const { - contentType, - propertyName, - expectedState, - expectedStateConjunction = 'every', - shouldCheckOptionalConnect = false, - targetId, - } = waitCondition || {}; - const models = await generateModels(subdomain); - - await setExecutionWaitAction(models, { - automationId: execution.automationId, - executionId: execution._id, - currentActionId: action.id, - responseActionId: action?.nextActionId, - condition: { - type: 'checkObject', - contentType: contentType || action.type, - propertyName, - expectedState, - expectedStateConjunction, - shouldCheckOptionalConnect, - targetId, - }, - }); - return 'pause'; - } - if (actionResponse.error) { - throw new Error(actionResponse.error); - } - - return actionResponse; -}; diff --git a/backend/services/automations/src/executions/handleExecutionActionResponse.ts b/backend/services/automations/src/executions/handleExecutionActionResponse.ts new file mode 100644 index 0000000000..40b12e24f5 --- /dev/null +++ b/backend/services/automations/src/executions/handleExecutionActionResponse.ts @@ -0,0 +1,14 @@ +import { + IAutomationExecAction, + IAutomationExecutionDocument, +} from 'erxes-api-shared/core-modules'; + +export const handleExecutionActionResponse = async ( + actionResponse: any, + execution: IAutomationExecutionDocument, + execAction: IAutomationExecAction, +) => { + execAction.result = actionResponse; + execution.actions = [...(execution.actions || []), execAction]; + execution = await execution.save(); +}; diff --git a/backend/services/automations/src/executions/handleExecutionError.ts b/backend/services/automations/src/executions/handleExecutionError.ts new file mode 100644 index 0000000000..7808cbb9b3 --- /dev/null +++ b/backend/services/automations/src/executions/handleExecutionError.ts @@ -0,0 +1,18 @@ +import { + AUTOMATION_EXECUTION_STATUS, + IAutomationExecAction, + IAutomationExecutionDocument, +} from 'erxes-api-shared/core-modules'; + +export const handleExecutionError = async ( + e, + actionType: string, + execution: IAutomationExecutionDocument, + execAction: IAutomationExecAction, +) => { + execAction.result = { error: e.message, result: e.result }; + execution.actions = [...(execution.actions || []), execAction]; + execution.status = AUTOMATION_EXECUTION_STATUS.ERROR; + execution.description = `An error occurred while working action: ${actionType}`; + await execution.save(); +}; diff --git a/backend/services/automations/src/executions/recieveTrigger.ts b/backend/services/automations/src/executions/recieveTrigger.ts index 28ce94ab94..f9280332ec 100644 --- a/backend/services/automations/src/executions/recieveTrigger.ts +++ b/backend/services/automations/src/executions/recieveTrigger.ts @@ -1,7 +1,7 @@ import { IModels } from '@/connectionResolver'; import { calculateExecution } from '@/executions/calculateExecutions'; import { executeActions } from '@/executions/executeActions'; -import { getActionsMap } from '@/utils/utils'; +import { getActionsMap } from '@/utils'; export const receiveTrigger = async ({ models, @@ -16,14 +16,9 @@ export const receiveTrigger = async ({ }) => { const automations = await models.Automations.find({ status: 'active', - $or: [ - { - 'triggers.type': { $in: [type] }, - }, - { - 'triggers.type': { $regex: `^${type}\\..*` }, - }, - ], + 'triggers.type': { + $in: [type, new RegExp(`^${type}\\..*`)], + }, }).lean(); if (!automations.length) { @@ -37,10 +32,6 @@ export const receiveTrigger = async ({ continue; } - // if (isWaitingDateConfig(trigger?.config?.dateConfig)) { - // continue; - // } - const execution = await calculateExecution({ models, subdomain, diff --git a/backend/services/automations/src/executions/setWaitActionResponse.ts b/backend/services/automations/src/executions/setWaitActionResponse.ts new file mode 100644 index 0000000000..05978c15c8 --- /dev/null +++ b/backend/services/automations/src/executions/setWaitActionResponse.ts @@ -0,0 +1,45 @@ +import { setExecutionWaitAction } from '@/bullmq/actionHandlerWorker/setWait'; +import { generateModels } from '@/connectionResolver'; +import { + AutomationExecutionSetWaitCondition, + EXECUTE_WAIT_TYPES, + IAutomationAction, + IAutomationExecutionDocument, +} from 'erxes-api-shared/core-modules'; + +export const setWaitActionResponse = async ( + subdomain: string, + execution: IAutomationExecutionDocument, + action: IAutomationAction, + { + contentType, + propertyName, + expectedState, + expectedStateConjunction = 'every', + shouldCheckOptionalConnect = false, + targetId, + }: Extract< + AutomationExecutionSetWaitCondition, + { type: EXECUTE_WAIT_TYPES.CHECK_OBJECT } + >, +) => { + const models = await generateModels(subdomain); + + await setExecutionWaitAction(models, { + automationId: execution.automationId, + executionId: execution._id, + currentActionId: action.id, + responseActionId: action?.nextActionId, + condition: { + type: EXECUTE_WAIT_TYPES.CHECK_OBJECT, + contentType: contentType || action.type, + propertyName, + expectedState, + expectedStateConjunction, + shouldCheckOptionalConnect, + targetId, + }, + }); + + return { shouldBreak: true, actionResponse: null }; +}; diff --git a/backend/services/automations/src/main.ts b/backend/services/automations/src/main.ts index 77cfc07e46..50213b6cea 100644 --- a/backend/services/automations/src/main.ts +++ b/backend/services/automations/src/main.ts @@ -9,8 +9,9 @@ import { } from 'erxes-api-shared/utils'; import express from 'express'; import * as http from 'http'; -import { initMQWorkers } from './bullmq'; +import { initMQWorkers } from './bullmq/initMQWorkers'; import { debugError, debugInfo } from '@/debuuger'; +import { webhookRoutes } from '@/executions/actions/webhook/incoming/webhookRoutes'; const { DOMAIN, @@ -54,6 +55,8 @@ app.use(cors(corsOptions)); app.get('/health', createHealthRoute(serviceName)); +app.use(webhookRoutes); + const httpServer = http.createServer(app); httpServer.listen(port, async () => { diff --git a/backend/services/automations/src/mongo/waitingActionsToExecute.ts b/backend/services/automations/src/mongo/waitingActionsToExecute.ts index 32496c3151..e7289edb98 100644 --- a/backend/services/automations/src/mongo/waitingActionsToExecute.ts +++ b/backend/services/automations/src/mongo/waitingActionsToExecute.ts @@ -1,20 +1,81 @@ +import { EXECUTE_WAIT_TYPES } from 'erxes-api-shared/core-modules'; import { Schema } from 'mongoose'; +import { z } from 'zod'; -export interface IAutomationWaitingAction { - automationId: string; - executionId: string; - currentActionId: string; - responseActionId: string; - conditionType: 'isInSegment' | 'checkObject'; - conditionConfig: any; - lastCheckedAt: Date; -} - -export interface IAutomationWaitingActionDocument - extends IAutomationWaitingAction, - Document { +const waitConditionTypes = [ + EXECUTE_WAIT_TYPES.IS_IN_SEGMENT, + EXECUTE_WAIT_TYPES.CHECK_OBJECT, + EXECUTE_WAIT_TYPES.WEBHOOK, +] as const; +export type WaitConditionType = (typeof waitConditionTypes)[number]; + +const automationWaitingActionCommon = z.object({ + automationId: z.string(), + executionId: z.string(), + currentActionId: z.string(), + responseActionId: z.string(), + lastCheckedAt: z.date(), +}); + +const automationWaitingActionDelay = z.object({ + conditionType: z.literal(EXECUTE_WAIT_TYPES.DELAY), + conditionConfig: z.object({ + subdomain: z.string(), + waitFor: z.number(), + timeUnit: z.enum(['minute', 'hour', 'day', 'month', 'year']), + startWaitingDate: z.date().optional(), + }), +}); + +const automationWaitingActionCheckObject = z.object({ + conditionType: z.literal(EXECUTE_WAIT_TYPES.CHECK_OBJECT), + conditionConfig: z.object({ + contentType: z.string().optional(), + shouldCheckOptionalConnect: z.boolean().optional(), + targetId: z.string(), + expectedState: z.record(z.any()), + propertyName: z.string(), + expectedStateConjunction: z.enum(['every', 'some']), + timeout: z.date(), + }), +}); + +const automationExecutionIsInSegment = z.object({ + conditionType: z.literal(EXECUTE_WAIT_TYPES.IS_IN_SEGMENT), + conditionConfig: z.object({ + targetId: z.string(), + segmentId: z.string(), + }), +}); + +const automationExecutionWebhook = z.object({ + conditionType: z.literal(EXECUTE_WAIT_TYPES.WEBHOOK), + conditionConfig: z.object({ + endpoint: z.string(), + secret: z.string(), + schema: z.string(), + }), +}); + +const conditionTypesSchema = z.discriminatedUnion('conditionType', [ + automationWaitingActionDelay, + automationWaitingActionCheckObject, + automationExecutionIsInSegment, + automationExecutionWebhook, +]); + +const automationWaitingActionSchema = z.intersection( + conditionTypesSchema, + automationWaitingActionCommon, +); + +export type IAutomationWaitingAction = z.infer< + typeof automationWaitingActionSchema +>; +export type IAutomationWaitingActionDocument = { _id: string; -} +} & IAutomationWaitingAction & + Document; export const waitingActionsToExecuteSchema = new Schema( { @@ -29,7 +90,7 @@ export const waitingActionsToExecuteSchema = new Schema( }, conditionType: { type: String, - enum: ['isInSegment', 'checkObject'], + enum: waitConditionTypes, required: true, index: true, }, diff --git a/backend/services/automations/src/types/aiAgentAction.ts b/backend/services/automations/src/types/aiAgentAction.ts new file mode 100644 index 0000000000..00ef294cf9 --- /dev/null +++ b/backend/services/automations/src/types/aiAgentAction.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; + +const aiAgentTopicSchema = z.object({ + id: z.string(), + topicName: z.string(), + prompt: z.string(), +}); + +const aiAgentObjectFieldSchema = z.object({ + id: z.string(), + fieldName: z.string(), + prompt: z.string(), + dataType: z.enum(['string', 'number', 'boolean', 'object', 'array']), + validation: z.string(), +}); + +const generateObjectSchema = z.object({ + goalType: z.literal('generateObject'), + objectFields: z.array(aiAgentObjectFieldSchema), +}); + +const classifyTopicSchema = z.object({ + goalType: z.literal('classifyTopic'), + topics: z.array(aiAgentTopicSchema), +}); + +const generateTextSchema = z.object({ + goalType: z.literal('generateText'), + prompt: z.string(), +}); + +const goalTypesSchema = z.discriminatedUnion('goalType', [ + generateTextSchema, + classifyTopicSchema, + generateObjectSchema, +]); + +const commonAiAgentConfigFormSchema = z.object({ aiAgentId: z.string() }); + +export const aiAgentConfigFormSchema = z.intersection( + goalTypesSchema, + commonAiAgentConfigFormSchema, +); + +export type TAiAgentConfigForm = z.infer; diff --git a/backend/services/automations/src/types/index.ts b/backend/services/automations/src/types/index.ts new file mode 100644 index 0000000000..4471551dfb --- /dev/null +++ b/backend/services/automations/src/types/index.ts @@ -0,0 +1,123 @@ +export type TDelayActionConfig = { + value: string; + type: 'minute' | 'hour' | 'day' | 'month' | 'year'; +}; + +export interface OutgoingHeaderItem { + key: string; + value: string; + type: 'fixed' | 'expression'; +} + +interface OutgoingQueryParamItem { + name: string; + value: string; + type: 'fixed' | 'expression'; +} + +export interface OutgoingRetryOptions { + attempts?: number; + delay?: number; + backoff?: 'none' | 'fixed' | 'exponential' | 'jitter'; +} + +interface OutgoingProxyOptions { + host?: string; + port?: number; + auth?: { username?: string; password?: string }; +} + +interface OutgoingOptions { + timeout?: number; + ignoreSSL?: boolean; + followRedirect?: boolean; + maxRedirects?: number; + retry?: OutgoingRetryOptions; + proxy?: OutgoingProxyOptions; +} + +interface BasicAuthConfig { + type: 'basic'; + username: string; + password: string; +} +interface BearerAuthConfig { + type: 'bearer'; + token: string; +} +interface NoneAuthConfig { + type: 'none'; +} +interface JwtAuthConfig { + type: 'jwt'; + algorithm: string; + secretKey: string; + publicKey?: string; + claims?: Record; + expiresIn?: string; + audience?: string; + issuer?: string; + header?: Record; + placement?: 'header' | 'query' | 'body'; +} + +export type OutgoingAuthConfig = + | BasicAuthConfig + | BearerAuthConfig + | NoneAuthConfig + | JwtAuthConfig; + +type HttpMethod = + | 'GET' + | 'POST' + | 'PUT' + | 'PATCH' + | 'DELETE' + | 'HEAD' + | 'OPTIONS'; + +export type TOutgoinWebhookActionConfig = { + method?: HttpMethod; + url: string; + queryParams?: OutgoingQueryParamItem[]; + body?: Record; + auth?: OutgoingAuthConfig; + headers?: OutgoingHeaderItem[]; + options?: OutgoingOptions; +}; + +export type TIfActionConfig = { + contentId: string; + yes: string; + no: string; +}; + +type IncomingWebhookHeaders = { + key: string; + value: string; + description: string; +}; + +type WebhookMethods = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + +type IncomingWebhookConfig = { + endpoint: string; + method: WebhookMethods; + headers: IncomingWebhookHeaders[]; + schema: any; + isEnabledSecurity?: string; + security?: { + beararToken: string; + secret: string; + }; + timeoutMs?: string; + maxRetries?: string; +}; + +export type TAutomationWaitEventConfig = { + targetType: 'trigger' | 'action' | 'custom'; + targetTriggerId?: string; + targetActionId?: string; + segmentId?: string; + webhookConfig?: IncomingWebhookConfig; +}; diff --git a/backend/services/automations/src/utils/actions/email.ts b/backend/services/automations/src/utils/actions/email.ts deleted file mode 100644 index 5312a92214..0000000000 --- a/backend/services/automations/src/utils/actions/email.ts +++ /dev/null @@ -1,553 +0,0 @@ -import { splitType } from 'erxes-api-shared/core-modules'; -import { - getEnv, - getPlugin, - getPlugins, - sendTRPCMessage, - sendWorkerMessage, -} from 'erxes-api-shared/utils'; -import { EMAIL_RECIPIENTS_TYPES } from '@/constants'; -import nodemailer from 'nodemailer'; -import AWS from 'aws-sdk'; -import { debugError } from '@/debuuger'; - -const generateEmails = (entry: string | any[], key?: string): string[] => { - const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; - - if (Array.isArray(entry)) { - if (key) { - entry = entry.map((item) => item?.[key]); - } - - return entry - .filter((value) => typeof value === 'string') - .map((value) => value.trim()) - .filter((value) => emailRegex.test(value)); - } - - if (typeof entry === 'string') { - return entry - .split(/[\s,;]+/) // split by space, comma, or semicolon - .map((value) => value.trim()) - .filter( - (value) => - value && - value.toLowerCase() !== 'null' && - value.toLowerCase() !== 'undefined' && - emailRegex.test(value), - ); - } - - return []; -}; - -const getSegmentEmails = async ({ - subdomain, - pluginName, - contentType, - execution, -}) => { - const { triggerConfig, targetId } = execution; - - const contentTypeIds = await sendTRPCMessage({ - pluginName: 'core', - method: 'query', - module: 'segments', - action: 'fetchSegment', - input: { - segmentId: triggerConfig.contentId, - options: { - defaultMustSelector: [ - { - match: { - _id: targetId, - }, - }, - ], - }, - }, - defaultValue: [], - }); - - if (contentType === 'user') { - return getTeamMemberEmails({ - subdomain, - params: { _id: { $in: contentTypeIds } }, - }); - } - - return await sendWorkerMessage({ - subdomain, - pluginName, - queueName: 'automations', - jobName: 'getRecipientsEmails', - data: { - type: contentType, - config: { - [`${contentType}Ids`]: contentTypeIds, - }, - }, - defaultValue: {}, - }); -}; - -const getAttributionEmails = async ({ - subdomain, - pluginName, - contentType, - target, - execution, - value, - key, -}) => { - let emails: string[] = []; - const matches = (value || '').match(/\{\{\s*([^}]+)\s*\}\}/g); - const attributes = (matches || []).map((match) => - match.replace(/\{\{\s*|\s*\}\}/g, ''), - ); - const relatedValueProps = {}; - - if (!attributes?.length) { - return []; - } - - for (const attribute of attributes) { - if (attribute === 'triggerExecutors') { - const executorEmails = await getSegmentEmails({ - subdomain, - pluginName, - contentType, - execution, - }); - emails = [...emails, ...executorEmails]; - } - - relatedValueProps[attribute] = { - key: 'email', - filter: { - key: 'registrationToken', - value: null, - }, - }; - - if (['customers', 'companies'].includes(attribute)) { - relatedValueProps[attribute] = { - key: 'primaryEmail', - }; - target[attribute] = null; - } - } - - const replacedContent = await sendWorkerMessage({ - subdomain, - pluginName, - queueName: 'automations', - jobName: 'replacePlaceHolders', - data: { - target: { ...target, type: contentType }, - config: { - [key]: value, - }, - relatedValueProps, - }, - defaultValue: {}, - }); - - const generatedEmails = generateEmails(replacedContent[key]); - - return [...emails, ...generatedEmails]; -}; - -export const getEmailRecipientTypes = async () => { - let reciepentTypes = [...EMAIL_RECIPIENTS_TYPES]; - - const plugins = await getPlugins(); - - for (const pluginName of plugins) { - const plugin = await getPlugin(pluginName); - const meta = plugin.config?.meta || {}; - - if (meta?.automations?.constants?.emailRecipientTypes) { - const { emailRecipientTypes } = meta?.automations?.constants || {}; - - reciepentTypes = [ - ...reciepentTypes, - ...emailRecipientTypes.map((eTR) => ({ ...eTR, pluginName })), - ]; - } - } - - return reciepentTypes; -}; - -const getTeamMemberEmails = async ({ subdomain, params }) => { - const users = await sendTRPCMessage({ - pluginName: 'core', - method: 'query', - module: 'users', - action: 'find', - input: { - query: { - ...params, - }, - }, - }); - - return generateEmails(users, 'email'); -}; - -export const getRecipientEmails = async ({ - subdomain, - config, - triggerType, - target, - execution, -}) => { - let toEmails: string[] = []; - const reciepentTypes: any = await getEmailRecipientTypes(); - - const reciepentTypeKeys = reciepentTypes.map((rT) => rT.name); - - for (const key of Object.keys(config)) { - if (reciepentTypeKeys.includes(key) && !!config[key]) { - const [pluginName, contentType] = splitType(triggerType); - - const { type, ...reciepentType } = reciepentTypes.find( - (rT) => rT.name === key, - ); - - if (type === 'teamMember') { - const emails = await getTeamMemberEmails({ - subdomain, - params: { - _id: { $in: config[key] || [] }, - }, - }); - - toEmails = [...toEmails, ...emails]; - continue; - } - - if (type === 'attributionMail') { - const emails = await getAttributionEmails({ - subdomain, - pluginName, - contentType, - target, - execution, - value: config[key], - key: type, - }); - - toEmails = [...toEmails, ...emails]; - continue; - } - - if (type === 'customMail') { - const emails = config[key] || []; - - toEmails = [...toEmails, ...emails]; - continue; - } - - if (!!reciepentType.pluginName) { - const emails = await sendWorkerMessage({ - subdomain, - pluginName: reciepentType.pluginName, - queueName: 'automations', - jobName: 'replacePlaceHolders', - data: { - type, - config, - }, - defaultValue: {}, - }); - - toEmails = [...toEmails, ...emails]; - continue; - } - } - } - - return [...new Set(toEmails)]; -}; - -const generateFromEmail = (sender, fromUserEmail) => { - if (sender && fromUserEmail) { - return `${sender} <${fromUserEmail}>`; - } - - if (fromUserEmail) { - return fromUserEmail; - } - - return null; -}; - -export const generateDoc = async ({ - subdomain, - target, - execution, - triggerType, - config, -}) => { - const { templateId, fromUserId, sender } = config; - const [pluginName, type] = triggerType.split(':'); - const version = getEnv({ name: 'VERSION' }); - const DEFAULT_AWS_EMAIL = getEnv({ name: 'DEFAULT_AWS_EMAIL' }); - - // const template = await sendCoreMessage({ - // subdomain, - // action: "emailTemplatesFindOne", - // data: { - // _id: templateId, - // }, - // isRPC: true, - // defaultValue: null, - // }); - - const template = { - content: 'Hello World', - }; - - let fromUserEmail = version === 'saas' ? DEFAULT_AWS_EMAIL : ''; - - if (fromUserId) { - const fromUser = await sendTRPCMessage({ - pluginName: 'core', - method: 'query', - module: 'users', - action: 'findOne', - input: { _id: fromUserId }, - }); - - fromUserEmail = fromUser?.email; - } - - let replacedContent = (template?.content || '').replace( - new RegExp(`{{\\s*${type}\\.\\s*(.*?)\\s*}}`, 'g'), - '{{ $1 }}', - ); - - // replacedContent = await replaceDocuments(subdomain, replacedContent, target); - - const { subject, content } = await sendWorkerMessage({ - subdomain, - pluginName, - queueName: 'automations', - jobName: 'replacePlaceHolders', - data: { - target, - config: { - subject: config.subject, - content: replacedContent, - }, - }, - defaultValue: {}, - }); - - const toEmails = await getRecipientEmails({ - subdomain, - config, - triggerType, - target, - execution, - }); - - if (!toEmails?.length) { - throw new Error('Receiving emails not found'); - } - - return { - title: subject, - fromEmail: generateFromEmail(sender, fromUserEmail), - toEmails: toEmails.filter((email) => fromUserEmail !== email), - customHtml: content, - }; -}; - -const getConfig = (configs, code, defaultValue?: string) => { - const version = getEnv({ name: 'VERSION' }); - - // if (version === 'saas') { - return getEnv({ name: code, defaultValue }); - // } - - // return configs[code] || defaultValue || ''; -}; - -const createTransporter = async ({ ses }, configs) => { - if (ses) { - const AWS_SES_ACCESS_KEY_ID = getConfig(configs, 'AWS_SES_ACCESS_KEY_ID'); - - const AWS_SES_SECRET_ACCESS_KEY = getConfig( - configs, - 'AWS_SES_SECRET_ACCESS_KEY', - ); - const AWS_REGION = getConfig(configs, 'AWS_REGION'); - - AWS.config.update({ - region: AWS_REGION, - accessKeyId: AWS_SES_ACCESS_KEY_ID, - secretAccessKey: AWS_SES_SECRET_ACCESS_KEY, - }); - - return nodemailer.createTransport({ - SES: new AWS.SES({ apiVersion: '2010-12-01' }), - }); - } - - const MAIL_SERVICE = configs['MAIL_SERVICE'] || ''; - const MAIL_PORT = configs['MAIL_PORT'] || ''; - const MAIL_USER = configs['MAIL_USER'] || ''; - const MAIL_PASS = configs['MAIL_PASS'] || ''; - const MAIL_HOST = configs['MAIL_HOST'] || ''; - - let auth; - - if (MAIL_USER && MAIL_PASS) { - auth = { - user: MAIL_USER, - pass: MAIL_PASS, - }; - } - - return nodemailer.createTransport({ - service: MAIL_SERVICE, - host: MAIL_HOST, - port: MAIL_PORT, - auth, - secure: MAIL_PORT === '465', - }); -}; - -const sendEmails = async ({ - subdomain, - params, -}: { - subdomain: string; - params: any; -}) => { - const { toEmails = [], fromEmail, title, customHtml, attachments } = params; - - const configs = await sendTRPCMessage({ - pluginName: 'core', - method: 'query', - module: 'configs', - action: 'getConfigs', - input: {}, - defaultValue: {}, - }); - - const NODE_ENV = getEnv({ name: 'NODE_ENV' }); - - const DEFAULT_EMAIL_SERVICE = getConfig( - configs, - 'DEFAULT_EMAIL_SERVICE', - 'SES', - ); - const COMPANY_EMAIL_FROM = getConfig(configs, 'COMPANY_EMAIL_FROM'); - const AWS_SES_CONFIG_SET = getConfig(configs, 'AWS_SES_CONFIG_SET'); - const AWS_SES_ACCESS_KEY_ID = getConfig(configs, 'AWS_SES_ACCESS_KEY_ID'); - const AWS_SES_SECRET_ACCESS_KEY = getConfig( - configs, - 'AWS_SES_SECRET_ACCESS_KEY', - ); - - if (!fromEmail && !COMPANY_EMAIL_FROM) { - throw new Error('From Email is required'); - } - - let transporter; - - try { - transporter = await createTransporter( - { ses: DEFAULT_EMAIL_SERVICE === 'SES' }, - configs, - ); - } catch (e) { - debugError(e.message); - throw new Error(e.message); - } - - const responses: any[] = []; - for (const toEmail of toEmails) { - const mailOptions: any = { - from: fromEmail || COMPANY_EMAIL_FROM, - to: toEmail, - subject: title, - html: customHtml, - attachments, - }; - let headers: { [key: string]: string } = {}; - - if (!!AWS_SES_ACCESS_KEY_ID?.length && !!AWS_SES_SECRET_ACCESS_KEY.length) { - // const emailDelivery = await sendCoreMessage({ - // subdomain, - // action: "emailDeliveries.create", - // data: { - // kind: "transaction", - // to: toEmail, - // from: fromEmail, - // subject: title, - // body: customHtml, - // status: "pending", - // }, - // isRPC: true, - // }); - // headers = { - // "X-SES-CONFIGURATION-SET": AWS_SES_CONFIG_SET || "erxes", - // EmailDeliveryId: emailDelivery && emailDelivery._id, - // }; - } else { - headers['X-SES-CONFIGURATION-SET'] = 'erxes'; - } - - mailOptions.headers = headers; - - if (!mailOptions.from) { - throw new Error(`"From" email address is missing: ${mailOptions.from}`); - } - - try { - const info = await transporter.sendMail(mailOptions); - responses.push({ messageId: info.messageId, toEmail }); - } catch (error) { - responses.push({ fromEmail, toEmail, error }); - debugError(error.message); - } - } - - return responses; -}; - -export const handleEmail = async ({ - subdomain, - target, - execution, - triggerType, - config, -}) => { - try { - const params = await generateDoc({ - subdomain, - triggerType, - target, - config, - execution, - }); - - if (!Object.keys(params)?.length) { - return { error: 'Something went wrong fetching data' }; - } - - const responses = await sendEmails({ - subdomain, - params, - }); - - return { ...params, responses }; - } catch (err) { - return { error: err.message }; - } -}; diff --git a/backend/services/automations/src/utils/cloudflare.ts b/backend/services/automations/src/utils/cloudflare.ts new file mode 100644 index 0000000000..2693b7b486 --- /dev/null +++ b/backend/services/automations/src/utils/cloudflare.ts @@ -0,0 +1,64 @@ +import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'; +import { getEnv } from 'erxes-api-shared/utils'; + +let s3: S3Client | null = null; + +const getS3Client = () => { + if (!s3) { + const endpoint = getEnv({ name: 'R2_ENDPOINT' }); + const accessKeyId = getEnv({ name: 'R2_ACCESS_KEY_ID' }); + const secretAccessKey = getEnv({ name: 'R2_SECRET_ACCESS_KEY' }); + + s3 = new S3Client({ + region: 'auto', + endpoint, + credentials: { accessKeyId, secretAccessKey }, + }); + } + return s3; +}; + +export const getFileAsStringFromCF = async (bucket: string, key: string) => { + const s3Client = getS3Client(); + const res = await s3Client + .send(new GetObjectCommand({ Bucket: bucket, Key: key })) + .catch((error) => { + throw error; + }); + + if (!res.Body) { + throw new Error('No body returned from S3 object'); + } + + const rawContent = await res.Body.transformToString(); + return rawContent; +}; + +export const getFileAsBufferFromCF = async (bucket: string, key: string) => { + const s3Client = getS3Client(); + const res = await s3Client + .send(new GetObjectCommand({ Bucket: bucket, Key: key })) + .catch((error) => { + throw error; + }); + + if (!res.Body) { + throw new Error('No body returned from S3 object'); + } + + // @ts-ignore aws v3 stream helper + const bytes = await res.Body.transformToByteArray(); + return Buffer.from(bytes); +}; + +export function chunkText(text: string, maxLength = 1000): string[] { + const chunks: string[] = []; + let start = 0; + + while (start < text.length) { + chunks.push(text.slice(start, start + maxLength)); + start += maxLength; + } + + return chunks; +} diff --git a/backend/services/automations/src/utils/utils.ts b/backend/services/automations/src/utils/index.ts similarity index 79% rename from backend/services/automations/src/utils/utils.ts rename to backend/services/automations/src/utils/index.ts index ec0ff7f2ad..d8ffdf8863 100644 --- a/backend/services/automations/src/utils/utils.ts +++ b/backend/services/automations/src/utils/index.ts @@ -1,7 +1,10 @@ -import { IAction, IActionsMap } from 'erxes-api-shared/core-modules'; +import { + IAutomationAction, + IAutomationActionsMap, +} from 'erxes-api-shared/core-modules'; -export const getActionsMap = async (actions: IAction[]) => { - const actionsMap: IActionsMap = {}; +export const getActionsMap = async (actions: IAutomationAction[]) => { + const actionsMap: IAutomationActionsMap = {}; for (const action of actions) { actionsMap[action.id] = action; @@ -10,7 +13,7 @@ export const getActionsMap = async (actions: IAction[]) => { return actionsMap; }; -const isDiffValue = (latest, target, field) => { +export const isDiffValue = (latest, target, field) => { if (field.includes('customFieldsData') || field.includes('trackedData')) { const [ct, fieldId] = field.split('.'); const latestFoundItem = latest[ct].find((i) => i.field === fieldId); diff --git a/backend/services/automations/src/utils/segments/utils.ts b/backend/services/automations/src/utils/isInSegment.ts similarity index 100% rename from backend/services/automations/src/utils/segments/utils.ts rename to backend/services/automations/src/utils/isInSegment.ts diff --git a/backend/services/automations/src/utils/sendAutomationWorkerMessage.ts b/backend/services/automations/src/utils/sendAutomationWorkerMessage.ts new file mode 100644 index 0000000000..be76f4f670 --- /dev/null +++ b/backend/services/automations/src/utils/sendAutomationWorkerMessage.ts @@ -0,0 +1,49 @@ +import { DefaultJobOptions, Queue, QueueEvents } from 'bullmq'; +import { redis } from 'erxes-api-shared/utils'; + +export async function sendAutomationWorkerMessage({ + queueName, + jobName, + subdomain, + data, + defaultValue, + timeout = 3000, + options, +}: { + queueName: string; + jobName: string; + subdomain: string; + data: TData; + defaultValue?: TResult; + timeout?: number; + options?: DefaultJobOptions; +}): Promise { + const queueKey = `automations-${queueName}`; + const queue = new Queue(queueKey, { connection: redis }); + const queueEvents = new QueueEvents(queueKey, { connection: redis }); + + const job = await queue.add( + jobName, + { subdomain, data }, + { ...(options || {}) }, + ); + + try { + const result = await Promise.race([ + job.waitUntilFinished(queueEvents) as Promise, + new Promise((_, reject) => + setTimeout( + () => + reject( + new Error(`Worker timeout: ${queueKey}/${jobName}/${job.id}`), + ), + timeout, + ), + ), + ]); + return result ?? defaultValue; + } catch (err) { + // enrich error, rethrow + throw err; + } +} diff --git a/backend/services/logs/src/bullmq/afterProcess.ts b/backend/services/logs/src/bullmq/afterProcess.ts index fe0bea2408..e40be6a811 100644 --- a/backend/services/logs/src/bullmq/afterProcess.ts +++ b/backend/services/logs/src/bullmq/afterProcess.ts @@ -1,20 +1,13 @@ +import { TAfterProcessProducers } from 'erxes-api-shared/core-modules'; import { getPlugin, getPlugins, IAfterProcessRule, - sendWorkerMessage, + sendCoreModuleProducer, TAfterProcessRule, } from 'erxes-api-shared/utils'; import { AfterProcessProps } from '~/types'; -interface WorkerMessage { - pluginName: string; - queueName: string; - jobName: string; - subdomain: string; - data: any; -} - type ProcessHandlerProps = { subdomain: string; pluginName: string; @@ -40,18 +33,6 @@ function getAllKeys(obj, prefix = '') { return keys; } -const sendProcessMessage = async (message: WorkerMessage): Promise => { - try { - sendWorkerMessage(message); - } catch (error) { - console.error( - `Failed to send worker message: ${ - error instanceof Error ? error.message : 'Unknown error' - }`, - ); - } -}; - const handleAfterMutation = ({ subdomain, pluginName, @@ -62,12 +43,11 @@ const handleAfterMutation = ({ const { mutationName } = payload || {}; if (mutationNames.includes(mutationName)) { - sendProcessMessage({ + sendCoreModuleProducer({ pluginName, - queueName: 'afterProcess', - jobName: 'onAfterMutation', - subdomain, - data: payload, + moduleName: 'afterProcess', + producerName: TAfterProcessProducers.AFTER_MUTATION, + input: payload, }); } }; @@ -99,12 +79,11 @@ const handleUpdatedDocument = ({ } if (shouldSend) { - sendProcessMessage({ + sendCoreModuleProducer({ pluginName, - queueName: 'afterProcess', - jobName: 'onDocumentUpdated', - subdomain, - data: { ...payload, contentType }, + moduleName: 'afterProcess', + producerName: TAfterProcessProducers.AFTER_DOCUMENT_UPDATED, + input: { ...payload, contentType }, }); } } @@ -130,12 +109,11 @@ const handleCreateDocument = ({ } if (shouldSend) { - sendProcessMessage({ + sendCoreModuleProducer({ pluginName, - queueName: 'afterProcess', - jobName: 'onDocumentCreated', - subdomain, - data: { ...payload, contentType }, + moduleName: 'afterProcess', + producerName: TAfterProcessProducers.AFTER_DOCUMENT_CREATED, + input: { ...payload, contentType }, }); } } @@ -150,12 +128,11 @@ const handleAfterAPIRequest = ({ const { paths = [] } = rule as TAfterProcessRule['AfterAPIRequest']; const { path } = payload || {}; if (paths.includes(path)) { - sendProcessMessage({ + sendCoreModuleProducer({ pluginName, - queueName: 'afterProcess', - jobName: 'onAfterApiRequest', - subdomain, - data: payload, + moduleName: 'afterProcess', + producerName: TAfterProcessProducers.AFTER_API_REQUEST, + input: payload, }); } }; @@ -170,12 +147,11 @@ const handleAfterAuth = ({ const { types = [] } = rule as TAfterProcessRule['AfterAuth']; if (types.includes(action)) { - sendProcessMessage({ + sendCoreModuleProducer({ pluginName, - queueName: 'afterProcess', - jobName: 'onAfterAuth', - subdomain, - data: { + moduleName: 'afterProcess', + producerName: TAfterProcessProducers.AFTER_AUTH, + input: { processId: payload.processId, userId: payload.userId, email: payload.email, diff --git a/backend/services/notifications/src/utils/email/emailUtils.ts b/backend/services/notifications/src/utils/email/emailUtils.ts index ca894feae7..eab80c7aa2 100644 --- a/backend/services/notifications/src/utils/email/emailUtils.ts +++ b/backend/services/notifications/src/utils/email/emailUtils.ts @@ -1,4 +1,4 @@ -import nodemailer from 'nodemailer'; +import * as nodemailer from 'nodemailer'; import AWS from 'aws-sdk'; import { IEmailTransportConfig } from '@/types'; import sendgridMail from '@sendgrid/mail'; diff --git a/frontend/core-ui/src/assets/cloudflare.webp b/frontend/core-ui/src/assets/cloudflare.webp new file mode 100644 index 0000000000..708a841eb9 Binary files /dev/null and b/frontend/core-ui/src/assets/cloudflare.webp differ diff --git a/frontend/core-ui/src/modules/app/components/AutomationRoutes.tsx b/frontend/core-ui/src/modules/app/components/AutomationRoutes.tsx index 1ba08dbfc6..4810fdb8f5 100644 --- a/frontend/core-ui/src/modules/app/components/AutomationRoutes.tsx +++ b/frontend/core-ui/src/modules/app/components/AutomationRoutes.tsx @@ -22,6 +22,10 @@ export const AutomationRoutes = () => { }> } /> + } + /> } diff --git a/frontend/core-ui/src/modules/automations/components/builder/AutomationBuilder.tsx b/frontend/core-ui/src/modules/automations/components/builder/AutomationBuilder.tsx index 397ace3cf7..c9fc3e2c8b 100644 --- a/frontend/core-ui/src/modules/automations/components/builder/AutomationBuilder.tsx +++ b/frontend/core-ui/src/modules/automations/components/builder/AutomationBuilder.tsx @@ -7,9 +7,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { FormProvider, useForm } from 'react-hook-form'; import { Tabs, useMultiQueryState } from 'erxes-ui'; -import { AutomationBuilderDnDProvider } from '../../context/AutomationBuilderDnDProvider'; -import { AutomationBuilderHeader } from './AutomationBuilderHeader'; -import { AutomationBuilderSidebar } from './sidebar/components/AutomationBuilderSidebar'; +import { AutomationBuilderDnDProvider } from '@/automations/context/AutomationBuilderDnDProvider'; import { InspectorPanel } from '@/automations/components/builder/InspectorPanel'; import { AutomationProvider } from '@/automations/context/AutomationProvider'; @@ -17,14 +15,15 @@ import { automationBuilderActiveTabState, automationBuilderSiderbarOpenState, } from '@/automations/states/automationState'; +import { deepCleanNulls } from '@/automations/utils/automationBuilderUtils/triggerUtils'; import { automationBuilderFormSchema, TAutomationBuilderForm, -} from '@/automations/utils/AutomationFormDefinitions'; +} from '@/automations/utils/automationFormDefinitions'; import { useAtom } from 'jotai'; -import { AutomationBuilderTabsType, IAutomation } from '../../types'; -import { deepCleanNulls } from '../../utils/automationBuilderUtils'; -import { AutomationHistories } from './history/components/AutomationHistories'; +import { AutomationBuilderTabsType, IAutomation } from '@/automations/types'; +import { AutomationBuilderHeader } from '@/automations/components/builder/header/AutomationBuilderHeader'; +import { AutomationHistories } from '@/automations/components/builder/history/components/AutomationHistories'; type AutomationBuilderProps = { detail?: IAutomation; @@ -67,15 +66,13 @@ export const AutomationBuilder = ({ detail }: AutomationBuilderProps) => { value="builder" className="flex-1 h-full relative" > - {/* */} - )} {activeTab === 'history' && ( diff --git a/frontend/core-ui/src/modules/automations/components/builder/AutomationBuilderCanvas.tsx b/frontend/core-ui/src/modules/automations/components/builder/AutomationBuilderCanvas.tsx index 0689233af0..e2527ab6f6 100644 --- a/frontend/core-ui/src/modules/automations/components/builder/AutomationBuilderCanvas.tsx +++ b/frontend/core-ui/src/modules/automations/components/builder/AutomationBuilderCanvas.tsx @@ -1,20 +1,10 @@ -import { AutomationBuilderEffect } from '@/automations/components/builder/AutomationBuilderEffect'; -import { PlaceHolderNode } from '@/automations/components/builder/nodes/PlaceHolderNode'; +import ConnectionLine from '@/automations/components/builder/edges/connectionLine'; +import { edgeTypes } from '@/automations/components/builder/edges/edgeTypesRegistry'; +import { nodeTypes } from '@/automations/components/builder/nodes/nodeTypesRegistry'; +import { AutomationBuilderSidebar } from '@/automations/components/builder/sidebar/components/AutomationBuilderSidebar'; +import { CANVAS_FIT_VIEW_OPTIONS } from '@/automations/constants'; import { useReactFlowEditor } from '@/automations/hooks/useReactFlowEditor'; import { Background, Controls, MiniMap, ReactFlow } from '@xyflow/react'; -import ConnectionLine from './edges/connectionLine'; -import PrimaryEdge from './edges/PrimaryEdge'; -import ActionNode from './nodes/ActionNode'; -import TriggerNode from './nodes/TriggerNode'; - -const nodeTypes = { - trigger: TriggerNode, - action: ActionNode, - scratch: PlaceHolderNode, -}; -const edgeTypes = { - primary: PrimaryEdge, -}; export const AutomationBuilderCanvas = () => { const { @@ -29,14 +19,12 @@ export const AutomationBuilderCanvas = () => { onDrop, isValidConnection, onNodeDoubleClick, - onNodeDragStop, onDragOver, setReactFlowInstance, } = useReactFlowEditor(); return ( -
- +
{ onInit={setReactFlowInstance} onDragOver={onDragOver} fitView + fitViewOptions={CANVAS_FIT_VIEW_OPTIONS} connectionLineComponent={ConnectionLine} - onNodeDragStop={onNodeDragStop} colorMode={theme} minZoom={0.5} > @@ -61,6 +49,7 @@ export const AutomationBuilderCanvas = () => { +
); }; diff --git a/frontend/core-ui/src/modules/automations/components/builder/AutomationBuilderEffect.tsx b/frontend/core-ui/src/modules/automations/components/builder/AutomationBuilderEffect.tsx deleted file mode 100644 index a86dbd4e6e..0000000000 --- a/frontend/core-ui/src/modules/automations/components/builder/AutomationBuilderEffect.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { useResetNodes } from '@/automations/hooks/useResetNodes'; -import { useTriggersActions } from '@/automations/hooks/useTriggersActions'; -import { useEffect } from 'react'; - -export const AutomationBuilderEffect = () => { - const { triggers, actions } = useTriggersActions(); - const { resetNodes } = useResetNodes(); - - useEffect(() => { - resetNodes(); - }, [JSON.stringify(triggers), JSON.stringify(actions)]); - - return <>; -}; diff --git a/frontend/core-ui/src/modules/automations/components/builder/InspectorPanel.tsx b/frontend/core-ui/src/modules/automations/components/builder/InspectorPanel.tsx index 170d59160c..a56ea7a054 100644 --- a/frontend/core-ui/src/modules/automations/components/builder/InspectorPanel.tsx +++ b/frontend/core-ui/src/modules/automations/components/builder/InspectorPanel.tsx @@ -1,22 +1,36 @@ import { AutomationBuilderCanvas } from '@/automations/components/builder/AutomationBuilderCanvas'; import { automationBuilderPanelOpenState, + automationBuilderSiderbarOpenState, toggleAutomationBuilderOpenPanel, + toggleAutomationBuilderOpenSidebar, } from '@/automations/states/automationState'; import { AutomationsHotKeyScope } from '@/automations/types'; import { + Icon, + IconLayoutBottombarExpand, + IconLayoutSidebarRightExpand, + IconProps, +} from '@tabler/icons-react'; +import { + Badge, + Button, + Command, PageSubHeader, Resizable, + Tooltip, usePreviousHotkeyScope, useScopedHotkeys, } from 'erxes-ui'; import { useAtomValue, useSetAtom } from 'jotai'; +import { useMemo } from 'react'; export const InspectorPanel = () => { const isPanelOpen = useAtomValue(automationBuilderPanelOpenState); const togglePanelOpen = useSetAtom(toggleAutomationBuilderOpenPanel); const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope(); - + const isOpenSideBar = useAtomValue(automationBuilderSiderbarOpenState); + const toggleSideBarOpen = useSetAtom(toggleAutomationBuilderOpenSidebar); const onOpen = () => { togglePanelOpen(); setHotkeyScopeAndMemorizePreviousScope(AutomationsHotKeyScope.Builder); @@ -24,11 +38,37 @@ export const InspectorPanel = () => { useScopedHotkeys(`mod+i`, () => onOpen(), AutomationsHotKeyScope.Builder); + const isMac = useMemo( + () => /Mac|iPod|iPhone|iPad/.test(navigator.platform), + [], + ); return ( {/* Canvas */} - + +
+ + +
{isPanelOpen && ( @@ -55,3 +95,45 @@ export const InspectorPanel = () => {
); }; + +const ToggleButton = ({ + isOpen, + onToggle, + openLabel, + closedLabel, + shortcut, + IconComponent, +}: { + isOpen: boolean; + onToggle: () => void; + openLabel: React.ReactNode; + closedLabel: React.ReactNode; + shortcut: string; + IconComponent: React.ForwardRefExoticComponent< + IconProps & React.RefAttributes + >; +}) => { + return ( + + + + + + {isOpen ? openLabel : closedLabel} + + {shortcut} + + + + ); +}; diff --git a/frontend/core-ui/src/modules/automations/components/builder/edges/AutomationBuilderHeaderActions.tsx b/frontend/core-ui/src/modules/automations/components/builder/edges/AutomationBuilderHeaderActions.tsx deleted file mode 100644 index 6fc001d2ec..0000000000 --- a/frontend/core-ui/src/modules/automations/components/builder/edges/AutomationBuilderHeaderActions.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { - automationBuilderActiveTabState, - automationBuilderPanelOpenState, - automationBuilderSiderbarOpenState, - toggleAutomationBuilderOpenPanel, - toggleAutomationBuilderOpenSidebar, -} from '@/automations/states/automationState'; -import { TAutomationBuilderForm } from '@/automations/utils/AutomationFormDefinitions'; -import { Button, Command, Form, Label, Switch } from 'erxes-ui'; -import { useAtomValue, useSetAtom } from 'jotai'; -import { useMemo } from 'react'; -import { useFormContext } from 'react-hook-form'; - -export const AutomationBuilderHeaderActions = () => { - const { control } = useFormContext(); - const isOpenSideBar = useAtomValue(automationBuilderSiderbarOpenState); - const toggleSideBarOpen = useSetAtom(toggleAutomationBuilderOpenSidebar); - - const isPanelOpen = useAtomValue(automationBuilderPanelOpenState); - const togglePanelOpen = useSetAtom(toggleAutomationBuilderOpenPanel); - const activeTab = useAtomValue(automationBuilderActiveTabState); - const isMac = useMemo( - () => /Mac|iPod|iPhone|iPad/.test(navigator.platform), - [], - ); - - if (activeTab !== 'builder') { - return null; - } - - return ( -
- ( - - -
- - - field.onChange(open ? 'active' : 'draft') - } - checked={field.value === 'active'} - /> -
-
-
- )} - /> - - - - -
- ); -}; diff --git a/frontend/core-ui/src/modules/automations/components/builder/edges/PrimaryEdge.tsx b/frontend/core-ui/src/modules/automations/components/builder/edges/PrimaryEdge.tsx index 2d51d2179a..a923e90169 100644 --- a/frontend/core-ui/src/modules/automations/components/builder/edges/PrimaryEdge.tsx +++ b/frontend/core-ui/src/modules/automations/components/builder/edges/PrimaryEdge.tsx @@ -1,20 +1,14 @@ -import { NodeData } from '@/automations/types'; -import { onDisconnect } from '@/automations/utils/automationConnectionUtils'; -import { TAutomationBuilderForm } from '@/automations/utils/AutomationFormDefinitions'; +import { useNodeConnect } from '@/automations/hooks/useNodeConnect'; import { IconScissors } from '@tabler/icons-react'; import { BaseEdge, - Edge, EdgeLabelRenderer, EdgeProps, getBezierPath, - Node, - useReactFlow, } from '@xyflow/react'; import { Button } from 'erxes-ui'; import { AnimatePresence, motion } from 'framer-motion'; import { FC } from 'react'; -import { useWatch } from 'react-hook-form'; const PrimaryEdge: FC = (edge) => { const { @@ -27,6 +21,7 @@ const PrimaryEdge: FC = (edge) => { targetPosition, selected, } = edge; + const { onDisconnect } = useNodeConnect(); const [edgePath, labelX, labelY] = getBezierPath({ sourceX, sourceY, @@ -36,14 +31,6 @@ const PrimaryEdge: FC = (edge) => { targetPosition, }); - const { getNodes, setEdges } = useReactFlow< - Node, - Edge - >(); - const [triggers = [], actions = []] = useWatch({ - name: ['triggers', 'actions'], - }); - return ( <> @@ -78,15 +65,7 @@ const PrimaryEdge: FC = (edge) => { variant="outline" className="rounded-full" size="icon" - onClick={() => { - onDisconnect({ - edge, - setEdges, - nodes: getNodes(), - triggers, - actions, - }); - }} + onClick={() => onDisconnect(edge)} > diff --git a/frontend/core-ui/src/modules/automations/components/builder/edges/connectionLine.tsx b/frontend/core-ui/src/modules/automations/components/builder/edges/connectionLine.tsx index c0a61b7ec7..eead75475d 100644 --- a/frontend/core-ui/src/modules/automations/components/builder/edges/connectionLine.tsx +++ b/frontend/core-ui/src/modules/automations/components/builder/edges/connectionLine.tsx @@ -1,5 +1,32 @@ -import { ConnectionLineComponentProps } from '@xyflow/react'; +import { useAutomation } from '@/automations/context/AutomationProvider'; +import { AutomationNodeType, NodeData } from '@/automations/types'; +import { + ConnectionLineComponentProps, + InternalNode, + Handle, + Node, +} from '@xyflow/react'; import { cn } from 'erxes-ui'; +import { IAutomationsActionFolkConfig } from 'ui-modules/modules/automations/types'; + +const getFolkTypeFn = ( + actionFolks: Record, + fromHandle: Handle, + fromNode: InternalNode, +) => { + fromNode.data.type; + const folks = + fromNode?.type === AutomationNodeType.Action + ? actionFolks[fromNode.data.type as string] + : null; + + const folkType = folks + ? (folks || [])?.find((folk) => + fromHandle.id?.includes(`${folk.key}-right`), + )?.type + : undefined; + return folkType; +}; const ConnectionLine = ({ fromX, @@ -9,16 +36,19 @@ const ConnectionLine = ({ ...props }: ConnectionLineComponentProps) => { const { fromHandle, fromNode } = props; + const { actionFolks } = useAutomation(); + + const folkType = getFolkTypeFn(actionFolks, fromHandle, fromNode); return ( diff --git a/frontend/core-ui/src/modules/automations/components/builder/edges/edgeTypesRegistry.ts b/frontend/core-ui/src/modules/automations/components/builder/edges/edgeTypesRegistry.ts new file mode 100644 index 0000000000..194472cb73 --- /dev/null +++ b/frontend/core-ui/src/modules/automations/components/builder/edges/edgeTypesRegistry.ts @@ -0,0 +1,5 @@ +import PrimaryEdge from '@/automations/components/builder/edges/PrimaryEdge'; + +export const edgeTypes = { + primary: PrimaryEdge, +}; diff --git a/frontend/core-ui/src/modules/automations/components/builder/AutomationBuilderHeader.tsx b/frontend/core-ui/src/modules/automations/components/builder/header/AutomationBuilderHeader.tsx similarity index 71% rename from frontend/core-ui/src/modules/automations/components/builder/AutomationBuilderHeader.tsx rename to frontend/core-ui/src/modules/automations/components/builder/header/AutomationBuilderHeader.tsx index 997af1bdc0..20cbb76f93 100644 --- a/frontend/core-ui/src/modules/automations/components/builder/AutomationBuilderHeader.tsx +++ b/frontend/core-ui/src/modules/automations/components/builder/header/AutomationBuilderHeader.tsx @@ -1,5 +1,5 @@ -import { AutomationBuilderHeaderActions } from '@/automations/components/builder/edges/AutomationBuilderHeaderActions'; -import { AutomationHeaderTabs } from '@/automations/components/builder/edges/AutomationHeaderTabs'; +import { AutomationBuilderHeaderActions } from '@/automations/components/builder/header/AutomationBuilderHeaderActions'; +import { AutomationHeaderTabs } from '@/automations/components/builder/header/AutomationHeaderTabs'; import { AutomationBuilderNameInput } from '@/automations/components/builder/header/AutomationBuilderNameInput'; import { IconAffiliate, IconSettings } from '@tabler/icons-react'; import { @@ -12,19 +12,13 @@ import { } from 'erxes-ui'; import { Link } from 'react-router'; import { PageHeader } from 'ui-modules'; -import { useAutomationHeader } from './hooks/useAutomationHeader'; +import { useAutomationHeader } from '@/automations/components/builder/hooks/useAutomationHeader'; export const AutomationBuilderHeader = () => { - const { - loading, - handleSubmit, - handleSave, - handleError, - toggleTabs, - isMobile, - } = useAutomationHeader(); + const { loading, handleSubmit, handleSave, handleError, toggleTabs } = + useAutomationHeader(); return ( -
+
@@ -56,20 +50,9 @@ export const AutomationBuilderHeader = () => { - -
+ +
-
diff --git a/frontend/core-ui/src/modules/automations/components/builder/header/AutomationBuilderHeaderActions.tsx b/frontend/core-ui/src/modules/automations/components/builder/header/AutomationBuilderHeaderActions.tsx new file mode 100644 index 0000000000..6b5ac61085 --- /dev/null +++ b/frontend/core-ui/src/modules/automations/components/builder/header/AutomationBuilderHeaderActions.tsx @@ -0,0 +1,39 @@ +import { automationBuilderActiveTabState } from '@/automations/states/automationState'; +import { TAutomationBuilderForm } from '@/automations/utils/automationFormDefinitions'; +import { Form, Label, Switch } from 'erxes-ui'; +import { useAtomValue } from 'jotai'; +import { useFormContext } from 'react-hook-form'; + +export const AutomationBuilderHeaderActions = () => { + const { control } = useFormContext(); + const activeTab = useAtomValue(automationBuilderActiveTabState); + + if (activeTab !== 'builder') { + return null; + } + + return ( +
+ ( + + +
+ + + field.onChange(open ? 'active' : 'draft') + } + checked={field.value === 'active'} + /> +
+
+
+ )} + /> +
+ ); +}; diff --git a/frontend/core-ui/src/modules/automations/components/builder/header/AutomationBuilderNameInput.tsx b/frontend/core-ui/src/modules/automations/components/builder/header/AutomationBuilderNameInput.tsx index 96eb9829a7..5d4ee3ac39 100644 --- a/frontend/core-ui/src/modules/automations/components/builder/header/AutomationBuilderNameInput.tsx +++ b/frontend/core-ui/src/modules/automations/components/builder/header/AutomationBuilderNameInput.tsx @@ -1,4 +1,4 @@ -import { TAutomationBuilderForm } from '@/automations/utils/AutomationFormDefinitions'; +import { TAutomationBuilderForm } from '@/automations/utils/automationFormDefinitions'; import { Form, Input } from 'erxes-ui'; import { useFormContext } from 'react-hook-form'; diff --git a/frontend/core-ui/src/modules/automations/components/builder/edges/AutomationHeaderTabs.tsx b/frontend/core-ui/src/modules/automations/components/builder/header/AutomationHeaderTabs.tsx similarity index 100% rename from frontend/core-ui/src/modules/automations/components/builder/edges/AutomationHeaderTabs.tsx rename to frontend/core-ui/src/modules/automations/components/builder/header/AutomationHeaderTabs.tsx diff --git a/frontend/core-ui/src/modules/automations/components/builder/history/AutomationHistoryRecordTableColumns.tsx b/frontend/core-ui/src/modules/automations/components/builder/history/AutomationHistoryRecordTableColumns.tsx new file mode 100644 index 0000000000..314677f1dd --- /dev/null +++ b/frontend/core-ui/src/modules/automations/components/builder/history/AutomationHistoryRecordTableColumns.tsx @@ -0,0 +1,74 @@ +import { AutomationHistoryDetail } from '@/automations/components/builder/history/components/AutomationHistoryDetail'; +import { AutomationHistoryResultName } from '@/automations/components/builder/history/components/AutomationHistoryResultName'; +import { AutomationHistoryTriggerCell } from '@/automations/components/builder/history/components/AutomationHistoryTriggerCell'; +import { STATUSES_BADGE_VARIABLES } from '@/automations/constants'; +import { StatusBadgeValue } from '@/automations/types'; +import { IconCalendarTime } from '@tabler/icons-react'; +import { ColumnDef } from '@tanstack/table-core'; +import dayjs from 'dayjs'; +import { + Badge, + RecordTable, + RecordTableInlineCell, + RelativeDateDisplay, +} from 'erxes-ui'; +import { IAutomationHistory } from 'ui-modules'; + +export const automationHistoriesColumns: ColumnDef[] = [ + { + id: 'more', + cell: ({ cell }) => , + size: 33, + }, + { + id: 'title', + accessorKey: 'title', + header: () => , + cell: AutomationHistoryResultName, + }, + { + id: 'description', + accessorKey: 'description', + header: () => , + cell: ({ cell }) => ( + {cell.getValue() as string} + ), + }, + { + id: 'trigger', + accessorKey: 'trigger', + header: () => , + cell: AutomationHistoryTriggerCell, + }, + + { + id: 'status', + accessorKey: 'status', + header: () => , + cell: ({ cell }) => { + const status = cell.getValue() as IAutomationHistory['status']; + + const variant: StatusBadgeValue = STATUSES_BADGE_VARIABLES[status]; + + return ( + + {status} + + ); + }, + }, + { + id: 'createdAt', + accessorKey: 'createdAt', + header: () => ( + + ), + cell: ({ cell }) => ( + + + + ), + }, +]; diff --git a/frontend/core-ui/src/modules/automations/components/builder/history/AutomtionHistoryRecorTableColumns.tsx b/frontend/core-ui/src/modules/automations/components/builder/history/AutomtionHistoryRecorTableColumns.tsx deleted file mode 100644 index 41300bf116..0000000000 --- a/frontend/core-ui/src/modules/automations/components/builder/history/AutomtionHistoryRecorTableColumns.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { AutomationHistoryDetail } from '@/automations/components/builder/history/components/AutomationHistoryDetail'; -import { STATUSES_BADGE_VARIABLES } from '@/automations/constants'; -import { StatusBadgeValue } from '@/automations/types'; -import { RenderPluginsComponentWrapper } from '@/automations/utils/RenderPluginsComponentWrapper'; -import { IconCalendarTime, IconInfoTriangle } from '@tabler/icons-react'; -import { ColumnDef } from '@tanstack/table-core'; -import dayjs from 'dayjs'; -import { - Badge, - RecordTable, - RecordTableInlineCell, - RelativeDateDisplay, -} from 'erxes-ui'; -import { - getAutomationTypes, - IAutomationHistory, - IAutomationsActionConfigConstants, - IAutomationsTriggerConfigConstants, -} from 'ui-modules'; - -export const automationHistoriesColumns = (constants: { - triggersConst: IAutomationsTriggerConfigConstants[]; - actionsConst: IAutomationsActionConfigConstants[]; -}): ColumnDef[] => [ - { - id: 'more', - cell: ({ cell }) => ( - - ), - size: 33, - }, - { - id: 'title', - accessorKey: 'title', - header: () => , - cell: ({ cell }) => { - const { triggerType, target } = cell.row.original; - const [pluginName, moduleName] = getAutomationTypes(triggerType); - - if (pluginName !== 'core' && moduleName) { - return ( - - ); - } - - if (pluginName && moduleName) { - return ( -

- {pluginName} - {moduleName} - -

- ); - } - - return 'Empty'; - }, - }, - { - id: 'description', - accessorKey: 'description', - header: () => , - cell: ({ cell }) => ( - {cell.getValue() as string} - ), - }, - { - id: 'trigger', - accessorKey: 'trigger', - header: () => , - cell: ({ cell }) => { - const triggerType = cell.row?.original?.triggerType; - - const triggerLabel = constants.triggersConst.find( - ({ type }) => type === triggerType, - )?.label; - - return ( - - {triggerLabel || triggerType || 'Empty'} - - ); - }, - }, - - { - id: 'status', - accessorKey: 'status', - header: () => , - cell: ({ cell }) => { - const status = cell.getValue() as IAutomationHistory['status']; - - const variant: StatusBadgeValue = STATUSES_BADGE_VARIABLES[status]; - - return ( - - {status} - - ); - }, - }, - { - id: 'createdAt', - accessorKey: 'createdAt', - header: () => ( - - ), - cell: ({ cell }) => ( - - - - ), - }, -]; diff --git a/frontend/core-ui/src/modules/automations/components/builder/history/components/AutomationHistories.tsx b/frontend/core-ui/src/modules/automations/components/builder/history/components/AutomationHistories.tsx index 16d6e60883..13fdb3b9e3 100644 --- a/frontend/core-ui/src/modules/automations/components/builder/history/components/AutomationHistories.tsx +++ b/frontend/core-ui/src/modules/automations/components/builder/history/components/AutomationHistories.tsx @@ -1,17 +1,15 @@ -import { automationHistoriesColumns } from '@/automations/components/builder/history/AutomtionHistoryRecorTableColumns'; +import { automationHistoriesColumns } from '@/automations/components/builder/history/AutomationHistoryRecordTableColumns'; import { useAutomationHistories } from '@/automations/hooks/useAutomationHistories'; -import { IconRefresh } from '@tabler/icons-react'; -import { Button, PageSubHeader, RecordTable, Skeleton } from 'erxes-ui'; -import { AutomationHistoriesRecordTableFilter } from './filters/AutomationRecordTableFilter'; +import { IconArchive, IconRefresh } from '@tabler/icons-react'; +import { Button, Label, PageSubHeader, RecordTable, Skeleton } from 'erxes-ui'; import { AUTOMATION_HISTORIES_CURSOR_SESSION_KEY } from '@/automations/constants'; +import { AutomationHistoriesRecordTableFilter } from '@/automations/components/builder/history/components/filters/AutomationRecordTableFilter'; export const AutomationHistories = () => { const { list, loading, totalCount, - triggersConst, - actionsConst, handleFetchMore, hasNextPage, hasPreviousPage, @@ -19,7 +17,7 @@ export const AutomationHistories = () => { } = useAutomationHistories(); return ( - <> +
@@ -31,11 +29,11 @@ export const AutomationHistories = () => { -
+
{ + {!totalCount && ( + + +
+ + +
+ + + )} {loading && } {
- +
); }; diff --git a/frontend/core-ui/src/modules/automations/components/builder/history/components/AutomationHistoryByFlow.tsx b/frontend/core-ui/src/modules/automations/components/builder/history/components/AutomationHistoryByFlow.tsx index 48446b1010..c6a702a0bf 100644 --- a/frontend/core-ui/src/modules/automations/components/builder/history/components/AutomationHistoryByFlow.tsx +++ b/frontend/core-ui/src/modules/automations/components/builder/history/components/AutomationHistoryByFlow.tsx @@ -1,106 +1,41 @@ -import { - generateEdges, - generateNodes, -} from '@/automations/utils/automationBuilderUtils'; -import { IconCheck, IconQuestionMark, IconX } from '@tabler/icons-react'; +import { useHistoryBeforeTitleContent } from '@/automations/components/builder/history/hooks/useHistoryBeforeTitleContent'; +import { useAutomation } from '@/automations/context/AutomationProvider'; +import { useAutomationNodes } from '@/automations/hooks/useAutomationNodes'; + import { Background, ConnectionMode, Controls, ReactFlow } from '@xyflow/react'; -import dayjs from 'dayjs'; -import { Badge, Label, Separator, Tooltip } from 'erxes-ui'; -import { useWatch } from 'react-hook-form'; -import { - IAutomationHistory, - IAutomationsActionConfigConstants, - IAutomationsTriggerConfigConstants, -} from 'ui-modules'; -import PrimaryEdge from '../../edges/PrimaryEdge'; -import ActionNode from '../../nodes/ActionNode'; -import TriggerNode from '../../nodes/TriggerNode'; -import { ExecutionActionResult } from './AutomationHistoryByTable'; -import { AutomationNodeType } from '@/automations/types'; +import { IAutomationHistory } from 'ui-modules'; +import { generateNodes } from '@/automations/utils/automationBuilderUtils/generateNodes'; +import { generateEdges } from '@/automations/utils/automationBuilderUtils/generateEdges'; +import TriggerNode from '@/automations/components/builder/nodes/components/TriggerNode'; +import ActionNode from '@/automations/components/builder/nodes/components/ActionNode'; +import PrimaryEdge from '@/automations/components/builder/edges/PrimaryEdge'; const nodeTypes = { - trigger: TriggerNode as any, - action: ActionNode as any, + trigger: TriggerNode, + action: ActionNode, }; const edgeTypes = { primary: PrimaryEdge, }; -const useBeforeTitleContent = (history: IAutomationHistory) => { - const beforeTitleContent = (id: string, type: AutomationNodeType) => { - const statusesMap = { - success: { icon: IconCheck, color: 'success' }, - error: { icon: IconX, color: 'error' }, - unknown: { icon: IconQuestionMark, color: 'accent' }, - }; - - let status: 'success' | 'error' | 'unknown' = 'unknown'; - let createdAt; - let content; - - if (type === 'trigger' && history.triggerId === id) { - status = 'success'; - createdAt = history.createdAt; - content = Passed; - } else if (type === 'action') { - const action = history?.actions?.find((a) => a.actionId === id); - status = action?.result?.error ? 'error' : action ? 'success' : 'unknown'; - createdAt = action?.createdAt; - content = action ? : ''; - } - - if (status === 'unknown') { - return null; - } - - const { icon: Icon, color } = statusesMap[status]; - - return ( - - - -
- -
-
- - {createdAt && ( - - )} - -
{content}
-
-
-
- ); - }; - - return { beforeTitleContent }; -}; - export const AutomationHistoryByFlow = ({ - constants, history, }: { history: IAutomationHistory; - constants: { - triggersConst: IAutomationsTriggerConfigConstants[]; - actionsConst: IAutomationsActionConfigConstants[]; - }; }) => { - const { triggers = [], actions = [] } = useWatch({ name: 'detail' }) || {}; + const { triggersConst, actionsConst } = useAutomation(); + const { triggers, actions, workflows } = useAutomationNodes(); - const { beforeTitleContent } = useBeforeTitleContent(history); + const { beforeTitleContent } = useHistoryBeforeTitleContent(history); + const nodes = generateNodes(triggers, actions, workflows, { + constants: { triggersConst, actionsConst }, + beforeTitleContent, + }); return (
; + return ; } - const isCoreAction = coreActionNames.includes(action?.actionType); + const isCoreAction = isCoreAutomationActionType( + action?.actionType, + TAutomationActionComponent.ActionResult, + ); - const [pluginName, moduleName] = getAutomationTypes(action?.actionType); + const [pluginName, moduleName] = splitAutomationNodeType(action?.actionType); + const { isEnabled } = useAutomationsRemoteModules(pluginName); - if (!isCoreAction && isEnabled(pluginName) && moduleName) { + if (!isCoreAction && isEnabled) { return ( { - const { actionsConst } = constants; + const { actionsConst } = useAutomation(); const { actions = [] } = history; return ( diff --git a/frontend/core-ui/src/modules/automations/components/builder/history/components/AutomationHistoryDetail.tsx b/frontend/core-ui/src/modules/automations/components/builder/history/components/AutomationHistoryDetail.tsx index 661195dcb5..09853c8875 100644 --- a/frontend/core-ui/src/modules/automations/components/builder/history/components/AutomationHistoryDetail.tsx +++ b/frontend/core-ui/src/modules/automations/components/builder/history/components/AutomationHistoryDetail.tsx @@ -1,27 +1,18 @@ +import { AutomationHistoryByFlow } from '@/automations/components/builder/history/components/AutomationHistoryByFlow'; +import { AutomationHistoryByTable } from '@/automations/components/builder/history/components/AutomationHistoryByTable'; import { IconAutomaticGearbox, IconEye, IconTournament, } from '@tabler/icons-react'; import { RecordTable, RecordTableInlineCell, Sheet, Tabs } from 'erxes-ui'; -import { - IAutomationHistory, - IAutomationsActionConfigConstants, - IAutomationsTriggerConfigConstants, -} from 'ui-modules'; -import { AutomationHistoryByFlow } from './AutomationHistoryByFlow'; -import { AutomationHistoryByTable } from './AutomationHistoryByTable'; import { useState } from 'react'; +import { IAutomationHistory } from 'ui-modules'; export const AutomationHistoryDetail = ({ history, - constants, }: { history: IAutomationHistory; - constants: { - triggersConst: IAutomationsTriggerConfigConstants[]; - actionsConst: IAutomationsActionConfigConstants[]; - }; }) => { const [isOpen, setOpen] = useState(false); @@ -34,49 +25,55 @@ export const AutomationHistoryDetail = ({ - {isOpen && ( - <> - -
- Execution history - - View the execution log of your automation in table or flow - format. - -
- -
- - - - - - View as table - - - - View as flow - - - - - - - - - - - - - )} +
); }; + +const AutomationHistorySheetContent = ({ + isOpen, + history, +}: { + isOpen: boolean; + history: IAutomationHistory; +}) => { + if (!isOpen) { + return null; + } + return ( + <> + +
+ Execution history + + View the execution log of your automation in table or flow format. + +
+ +
+ + + + + + View as table + + + + View as flow + + + + + + + + + + + + + ); +}; diff --git a/frontend/core-ui/src/modules/automations/components/builder/history/components/AutomationHistoryResultName.tsx b/frontend/core-ui/src/modules/automations/components/builder/history/components/AutomationHistoryResultName.tsx new file mode 100644 index 0000000000..e1f216b09e --- /dev/null +++ b/frontend/core-ui/src/modules/automations/components/builder/history/components/AutomationHistoryResultName.tsx @@ -0,0 +1,37 @@ +import { RenderPluginsComponentWrapper } from '@/automations/components/common/RenderPluginsComponentWrapper'; +import { IconInfoTriangle } from '@tabler/icons-react'; +import { CellContext } from '@tanstack/table-core'; +import { splitAutomationNodeType, IAutomationHistory } from 'ui-modules'; + +export const AutomationHistoryResultName = ({ + cell, +}: CellContext) => { + const { triggerType, target } = cell.row.original; + const [pluginName, moduleName] = splitAutomationNodeType(triggerType); + + if (pluginName !== 'core' && moduleName) { + return ( + + ); + } + + if (pluginName && moduleName) { + return ( +

+ {pluginName} + {moduleName} + +

+ ); + } + + return 'Empty'; +}; diff --git a/frontend/core-ui/src/modules/automations/components/builder/history/components/AutomationHistoryTriggerCell.tsx b/frontend/core-ui/src/modules/automations/components/builder/history/components/AutomationHistoryTriggerCell.tsx new file mode 100644 index 0000000000..660f18798d --- /dev/null +++ b/frontend/core-ui/src/modules/automations/components/builder/history/components/AutomationHistoryTriggerCell.tsx @@ -0,0 +1,21 @@ +import { useAutomation } from '@/automations/context/AutomationProvider'; +import { CellContext } from '@tanstack/table-core'; +import { RecordTableInlineCell } from 'erxes-ui'; +import { IAutomationHistory } from 'ui-modules'; + +export const AutomationHistoryTriggerCell = ({ + cell, +}: CellContext) => { + const triggerType = cell.row?.original?.triggerType; + const { triggersConst } = useAutomation(); + + const triggerLabel = triggersConst.find( + ({ type }) => type === triggerType, + )?.label; + + return ( + + {triggerLabel || triggerType || 'Empty'} + + ); +}; diff --git a/frontend/core-ui/src/modules/automations/components/builder/history/hooks/useHistoryBeforeTitleContent.tsx b/frontend/core-ui/src/modules/automations/components/builder/history/hooks/useHistoryBeforeTitleContent.tsx new file mode 100644 index 0000000000..ae66a32ff7 --- /dev/null +++ b/frontend/core-ui/src/modules/automations/components/builder/history/hooks/useHistoryBeforeTitleContent.tsx @@ -0,0 +1,87 @@ +import { ExecutionActionResult } from '@/automations/components/builder/history/components/AutomationHistoryByTable'; +import { AutomationNodeType } from '@/automations/types'; +import { IconCheck, IconQuestionMark, IconX } from '@tabler/icons-react'; +import dayjs from 'dayjs'; +import { Badge, cn, Label, Popover, Separator } from 'erxes-ui'; +import { IAutomationHistory } from 'ui-modules'; + +type HistoryStatusType = 'success' | 'error' | 'unknown'; + +const STATUS_MAP: Record< + HistoryStatusType, + { icon: React.ElementType; className: string } +> = { + success: { + icon: IconCheck, + className: 'text-success bg-success/10 border-success', + }, + error: { icon: IconX, className: 'text-error bg-error/10 border-error' }, + unknown: { + icon: IconQuestionMark, + className: 'text-accent bg-accent/10 border-accent', + }, +}; + +export const useHistoryBeforeTitleContent = (history: IAutomationHistory) => { + const beforeTitleContent = (id: string, type: AutomationNodeType) => { + const data = getHistoryContent(history, id, type); + if (!data) return null; + + const { status, createdAt, content } = data; + const { icon: Icon, className } = STATUS_MAP[status]; + + return ( + + +
+ +
+
+ + {createdAt && ( + + )} + +
{content}
+
+
+ ); + }; + + return { beforeTitleContent }; +}; + +const getHistoryContent = ( + history: IAutomationHistory, + id: string, + type: AutomationNodeType, +) => { + if (type === 'trigger' && history.triggerId === id) { + return { + status: 'success' as HistoryStatusType, + createdAt: history.createdAt, + content: Passed, + }; + } + + if (type === 'action') { + const action = history?.actions?.find((a) => a.actionId === id); + const status: HistoryStatusType = action + ? action.result?.error + ? 'error' + : 'success' + : 'unknown'; + + if (status === 'unknown') { + return null; + } + + return { + status, + createdAt: action?.createdAt, + content: action ? : null, + }; + } + + return null; +}; diff --git a/frontend/core-ui/src/modules/automations/components/builder/hooks/useAutomationHeader.ts b/frontend/core-ui/src/modules/automations/components/builder/hooks/useAutomationHeader.ts index aa505ab220..ea9fc0b594 100644 --- a/frontend/core-ui/src/modules/automations/components/builder/hooks/useAutomationHeader.ts +++ b/frontend/core-ui/src/modules/automations/components/builder/hooks/useAutomationHeader.ts @@ -1,73 +1,86 @@ +import { useNodeErrorHandler } from '@/automations/components/builder/hooks/useNodeErrorHandler'; import { useAutomation } from '@/automations/context/AutomationProvider'; import { AUTOMATION_CREATE, AUTOMATION_EDIT, } from '@/automations/graphql/automationMutations'; -import { useTriggersActions } from '@/automations/hooks/useTriggersActions'; -import { AutomationBuilderTabsType } from '@/automations/types'; -import { TAutomationBuilderForm } from '@/automations/utils/AutomationFormDefinitions'; +import { useAutomationNodes } from '@/automations/hooks/useAutomationNodes'; +import { useAutomationFormController } from '@/automations/hooks/useFormSetValue'; +import { AutomationBuilderTabsType, NodeData } from '@/automations/types'; +import { TAutomationBuilderForm } from '@/automations/utils/automationFormDefinitions'; import { useMutation } from '@apollo/client'; -import { useReactFlow } from '@xyflow/react'; -import { useIsMobile, toast } from 'erxes-ui'; +import { Node, useReactFlow } from '@xyflow/react'; +import { toast } from 'erxes-ui'; import { SubmitErrorHandler, useFormContext } from 'react-hook-form'; -import { useParams } from 'react-router'; +import { useNavigate, useParams } from 'react-router'; export const useAutomationHeader = () => { const { handleSubmit, clearErrors } = useFormContext(); + const navigate = useNavigate(); + const { setAutomationBuilderFormValue, syncPositionUpdates } = + useAutomationFormController(); const { setQueryParams, reactFlowInstance } = useAutomation(); - const { actions, triggers } = useTriggersActions(); - const isMobile = useIsMobile(); + const { actions, triggers } = useAutomationNodes(); const { getNodes, setNodes } = useReactFlow(); const { id } = useParams(); + const { handleNodeErrors, clearNodeErrors } = useNodeErrorHandler({ + reactFlowInstance, + getNodes: getNodes as () => Node[], + setNodes: setNodes as (nodes: Node[]) => void, + }); + const [save, { loading }] = useMutation( id ? AUTOMATION_EDIT : AUTOMATION_CREATE, ); - const handleSave = async (values: TAutomationBuilderForm) => { - const { triggers, actions, name, status } = values; + const handleSave = async ({ + triggers, + actions, + name, + status, + workflows, + }: TAutomationBuilderForm) => { + // Sync all pending position updates to form state + syncPositionUpdates(); + const generateValues = () => { return { id, name, status: status, - triggers: triggers.map((t) => ({ - id: t.id, - type: t.type, - config: t.config, - icon: t.icon, - label: t.label, - description: t.description, - actionId: t.actionId, - position: t.position, - isCustom: t.isCustom, - })), - actions: actions.map((a) => ({ - id: a.id, - type: a.type, - nextActionId: a.nextActionId, - config: a.config, - icon: a.icon, - label: a.label, - description: a.description, - position: a.position, - })), + triggers, + actions, + workflows, }; }; - return save({ variables: generateValues() }).then(() => { - clearErrors(); - toast({ - title: 'Save successful', - }); + return save({ + variables: generateValues(), + onError: (error) => { + toast({ + title: 'Something went wrong', + description: error.message, + variant: 'destructive', + }); + }, + onCompleted: ({ automationsAdd }) => { + clearErrors(); + clearNodeErrors(); + toast({ + title: 'Save successful', + }); + if (!id && automationsAdd) { + navigate(`/automations/edit/${automationsAdd._id}`); + } + }, }); }; const handleError: SubmitErrorHandler = (errors) => { - const nodes = getNodes(); const { triggers: triggersErrors, actions: actionsErrors } = errors || {}; const nodeErrorMap: Record = {}; @@ -91,34 +104,24 @@ export const useAutomationHeader = () => { } if (Object.keys(nodeErrorMap).length > 0) { - const updatedNodes = nodes.map((node) => ({ - ...node, - data: { - ...node.data, - error: nodeErrorMap[node.id], - }, - })); - - setNodes(updatedNodes); - - // Focus on first error node - const firstErrorNode = updatedNodes.find((n) => nodeErrorMap[n.id]); - if (firstErrorNode && reactFlowInstance) { - reactFlowInstance.fitView({ - nodes: [firstErrorNode], - duration: 800, - }); - } + // Use the new error handler + handleNodeErrors(nodeErrorMap); } else { const errorKeys = Object.keys(errors || {}); if (errorKeys?.length > 0) { - const errorMessage = (errors as Record)[ - errorKeys[0] - ]?.message; + const { message, ref } = + (errors as Record)[ + errorKeys[0] + ] || {}; toast({ - title: 'Error', - description: errorMessage, + title: 'Something went wrong', + description: message, + variant: 'destructive', }); + + if (ref) { + ref?.focus(); + } } } }; @@ -132,6 +135,5 @@ export const useAutomationHeader = () => { handleSave, handleError, toggleTabs, - isMobile, }; }; diff --git a/frontend/core-ui/src/modules/automations/components/builder/hooks/useAutomationTrigger.ts b/frontend/core-ui/src/modules/automations/components/builder/hooks/useAutomationTrigger.ts index 6f89392e25..a68a626192 100644 --- a/frontend/core-ui/src/modules/automations/components/builder/hooks/useAutomationTrigger.ts +++ b/frontend/core-ui/src/modules/automations/components/builder/hooks/useAutomationTrigger.ts @@ -1,5 +1,5 @@ -import { getContentType } from '@/automations/utils/automationBuilderUtils'; -import { TAutomationBuilderForm } from '@/automations/utils/AutomationFormDefinitions'; +import { findTriggerForAction } from '@/automations/utils/automationBuilderUtils/triggerUtils'; +import { TAutomationBuilderForm } from '@/automations/utils/automationFormDefinitions'; import { useWatch } from 'react-hook-form'; export const useAutomationTrigger = (currentActionId: string) => { @@ -7,7 +7,7 @@ export const useAutomationTrigger = (currentActionId: string) => { name: ['actions', 'triggers'], }); - const trigger = getContentType(currentActionId, actions, triggers); + const trigger = findTriggerForAction(currentActionId, actions, triggers); return { trigger, diff --git a/frontend/core-ui/src/modules/automations/components/builder/hooks/useNodeErrorHandler.ts b/frontend/core-ui/src/modules/automations/components/builder/hooks/useNodeErrorHandler.ts new file mode 100644 index 0000000000..22131b098f --- /dev/null +++ b/frontend/core-ui/src/modules/automations/components/builder/hooks/useNodeErrorHandler.ts @@ -0,0 +1,143 @@ +import { ReactFlowInstance, Node, Edge, EdgeProps } from '@xyflow/react'; +import { NodeData } from '@/automations/types'; + +interface UseNodeErrorHandlerProps { + reactFlowInstance: ReactFlowInstance, Edge> | null; + getNodes: () => Node[]; + setNodes: (nodes: Node[]) => void; +} + +interface NodeErrorMap { + [nodeId: string]: string; +} + +export const useNodeErrorHandler = ({ + reactFlowInstance, + getNodes, + setNodes, +}: UseNodeErrorHandlerProps) => { + /** + * Updates nodes with error information and focuses on the first error node + * @param nodeErrorMap - Object mapping node IDs to error messages + * @param focusToError - Whether to focus/zoom to the first error node (default: true) + */ + const handleNodeErrors = ( + nodeErrorMap: NodeErrorMap, + focusToError: boolean = true, + ) => { + if (Object.keys(nodeErrorMap).length === 0) { + return; + } + + const nodes = getNodes(); + const updatedNodes = nodes.map((node) => ({ + ...node, + data: { + ...node.data, + error: nodeErrorMap[node.id] || undefined, + }, + })); + + setNodes(updatedNodes); + + // Focus on first error node if requested + if (focusToError) { + const firstErrorNode = updatedNodes.find((n) => nodeErrorMap[n.id]); + if (firstErrorNode && reactFlowInstance) { + reactFlowInstance.fitView({ + nodes: [firstErrorNode], + duration: 800, + }); + } + } + }; + + /** + * Clears all errors from nodes + */ + const clearNodeErrors = () => { + const nodes = getNodes(); + const updatedNodes = nodes.map((node) => ({ + ...node, + data: { + ...node.data, + error: undefined, + }, + })); + setNodes(updatedNodes); + }; + + /** + * Clears error from a specific node + * @param nodeId - ID of the node to clear error from + */ + const clearNodeError = (nodeId: string) => { + const nodes = getNodes(); + const updatedNodes = nodes.map((node) => { + if (node.id === nodeId) { + return { + ...node, + data: { + ...node.data, + error: undefined, + }, + }; + } + return node; + }); + setNodes(updatedNodes); + }; + + /** + * Sets error for a specific node + * @param nodeId - ID of the node to set error for + * @param errorMessage - Error message to display + * @param focusToError - Whether to focus on this error node (default: false) + */ + const setNodeError = ( + nodeId: string, + errorMessage: string, + focusToError: boolean = false, + ) => { + handleNodeErrors({ [nodeId]: errorMessage }, focusToError); + }; + + /** + * Gets all nodes that currently have errors + */ + const getErrorNodes = () => { + const nodes = getNodes(); + return nodes.filter((node) => node.data.error); + }; + + /** + * Gets detailed error information for banner display + */ + const getErrorDetails = () => { + const nodes = getNodes(); + return nodes + .filter((node) => node.data.error) + .map((node) => ({ + nodeId: node.id, + nodeLabel: node.data.label, + error: node.data.error, + })); + }; + + /** + * Checks if any nodes have errors + */ + const hasErrors = () => { + return getErrorNodes().length > 0; + }; + + return { + handleNodeErrors, + clearNodeErrors, + clearNodeError, + setNodeError, + getErrorNodes, + getErrorDetails, + hasErrors, + }; +}; diff --git a/frontend/core-ui/src/modules/automations/components/builder/nodes/ActionNode.tsx b/frontend/core-ui/src/modules/automations/components/builder/nodes/ActionNode.tsx deleted file mode 100644 index 07b12953c3..0000000000 --- a/frontend/core-ui/src/modules/automations/components/builder/nodes/ActionNode.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { NodeOutputHandler } from '@/automations/components/builder/nodes/NodeOutputHandler'; -import { IconAdjustmentsAlt } from '@tabler/icons-react'; -import { Handle, Node, NodeProps, Position } from '@xyflow/react'; -import { cn, IconComponent } from 'erxes-ui'; -import { memo } from 'react'; -import { NodeData } from '../../../types'; -import { ErrorState } from '../../../utils/ErrorState'; -import { ActionNodeConfigurationContent } from './ActionNodeConfigurationContent'; -import { NodeDropdownActions } from './NodeDropdownActions'; - -const ActionNodeContent = ({ data }: { data: NodeData }) => { - if (data?.error) { - return ( - - ); - } - - if (data.type === 'if') { - return null; - } - - if (!Object.keys(data?.config || {}).length) { - return null; - } - - return ( -
-
- -

Configuration

-
-
- -
-
- ); -}; - -const ActionNodeSourceHandler = ({ - id, - type, - nextActionId, - config, -}: { - id: string; - type: string; - nextActionId?: string; - config?: any; -}) => { - if (type === 'if') { - return ( - <> - -
- True -
-
- -
- False -
-
- - ); - } - - return ( - - ); -}; - -const ActionNode = ({ data, selected, id }: NodeProps>) => { - const { beforeTitleContent, config, nextActionId } = data; - - return ( -
-
-

Action

-
-
-
-
- {beforeTitleContent && beforeTitleContent(id, 'action')} - -
- -
- {data.label} -
- -
- -
-
- -
- - {data.description} - -
- - - - - -
-
- ); -}; - -export default memo(ActionNode); diff --git a/frontend/core-ui/src/modules/automations/components/builder/nodes/ActionNodeConfigurationContent.tsx b/frontend/core-ui/src/modules/automations/components/builder/nodes/ActionNodeConfigurationContent.tsx deleted file mode 100644 index 4763f65e72..0000000000 --- a/frontend/core-ui/src/modules/automations/components/builder/nodes/ActionNodeConfigurationContent.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { useActionNodeConfiguration } from '@/automations/components/builder/nodes/hooks/useActionNodeConfiguration'; -import { NodeData } from '@/automations/types'; - -export const ActionNodeConfigurationContent = ({ - data, -}: { - data: NodeData; -}) => { - const { Component } = useActionNodeConfiguration(data); - - return Component || null; -}; diff --git a/frontend/core-ui/src/modules/automations/components/builder/nodes/NodeDropdownActions.tsx b/frontend/core-ui/src/modules/automations/components/builder/nodes/NodeDropdownActions.tsx deleted file mode 100644 index 97df17844c..0000000000 --- a/frontend/core-ui/src/modules/automations/components/builder/nodes/NodeDropdownActions.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { useNodeDropDownActions } from './hooks/useNodeDropDownActions'; -import { IconDotsVertical, IconEdit, IconTrash } from '@tabler/icons-react'; -import { AlertDialog, Button, Dialog, DropdownMenu } from 'erxes-ui'; -import { Dispatch, SetStateAction } from 'react'; -import { AutomationNodesType, NodeData } from '../../../types'; -import { EditForm } from './NodeEditForm'; - -export const NodeDropdownActions = ({ - id, - data, -}: { - id: string; - data: NodeData; -}) => { - const { - fieldName, - isOpenDialog, - isOpenDropDown, - setOpenDialog, - setOpenDropDown, - onRemoveNode, - } = useNodeDropDownActions(id, data.nodeType); - - return ( - { - if (!isOpenDialog) { - setOpenDropDown(open); - } - }} - > - - - - - - - - - - - - - ); -}; - -const NodeRemoveActionDialog = ({ - onRemoveNode, -}: { - onRemoveNode: () => void; -}) => { - return ( - - - - - - - Are you absolutely sure? - - This action cannot be undone. - - - - Cancel - - Continue - - - - - ); -}; - -const NodeEditForm = ({ - isOpenDialog, - setOpenDialog, - data, - id, - fieldName, -}: { - isOpenDialog: boolean; - setOpenDialog: Dispatch>; - data: NodeData; - id: string; - fieldName: AutomationNodesType; -}) => { - return ( - { - setOpenDialog(open); - }} - > - - - - setOpenDialog(false)} - /> - - ); -}; diff --git a/frontend/core-ui/src/modules/automations/components/builder/nodes/NodeEditForm.tsx b/frontend/core-ui/src/modules/automations/components/builder/nodes/NodeEditForm.tsx deleted file mode 100644 index b55532125d..0000000000 --- a/frontend/core-ui/src/modules/automations/components/builder/nodes/NodeEditForm.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { TAutomationBuilderForm } from '@/automations/utils/AutomationFormDefinitions'; -import { Button, Dialog, Input } from 'erxes-ui'; -import { useState } from 'react'; -import { useFormContext } from 'react-hook-form'; -import { NodeData } from '../../../types'; - -type Props = { - id: string; - fieldName: 'actions' | 'triggers'; - data: NodeData; - callback: () => void; -}; - -export const EditForm = ({ id, fieldName, data, callback }: Props) => { - const { setValue } = useFormContext(); - - const { nodeIndex, label, description } = data || {}; - - const [doc, setDoc] = useState({ - label: label || '', - description: description || '', - }); - - const handleChange = (e: any) => { - const { value, name } = e.currentTarget as HTMLInputElement; - - setDoc({ ...doc, [name]: value }); - }; - - const handleSave = () => { - setValue( - `${fieldName}.${nodeIndex}`, - { - ...data, - label: doc.label, - description: doc.description, - }, - { shouldValidate: true, shouldDirty: true }, - ); - - callback(); - }; - - return ( - - Edit Node - - - - - - - ); -}; diff --git a/frontend/core-ui/src/modules/automations/components/builder/nodes/NodeOutputHandler.tsx b/frontend/core-ui/src/modules/automations/components/builder/nodes/NodeOutputHandler.tsx deleted file mode 100644 index c81846d60b..0000000000 --- a/frontend/core-ui/src/modules/automations/components/builder/nodes/NodeOutputHandler.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { useAutomation } from '@/automations/context/AutomationProvider'; -import { AutomationNodeType } from '@/automations/types'; -import { IconLinkPlus, IconPlus } from '@tabler/icons-react'; -import { Handle, Position } from '@xyflow/react'; -import { Button, cn } from 'erxes-ui'; -import { AnimatePresence, motion } from 'framer-motion'; -import React, { memo, useCallback } from 'react'; - -interface NodeOutputHandlerProps extends React.HTMLAttributes { - handlerId: string; - nodeType: AutomationNodeType; - showAddButton: boolean; - addButtonClassName?: string; -} - -const AwaitToConnectButton = memo( - ({ - nodeType, - nodeHandleId, - addButtonClassName, - }: { - nodeType: AutomationNodeType; - addButtonClassName?: string; - nodeHandleId: string; - }) => { - const { - awaitingToConnectNodeId, - setAwaitingToConnectNodeId, - setQueryParams, - } = useAutomation(); - - const handleClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - if (!awaitingToConnectNodeId) { - setQueryParams({ activeNodeId: null }); - } - setAwaitingToConnectNodeId( - awaitingToConnectNodeId === nodeHandleId ? '' : nodeHandleId, - ); - }, - [awaitingToConnectNodeId, nodeHandleId], - ); - - const isSelected = awaitingToConnectNodeId === nodeHandleId; - - const IconComponent = ( - - {isSelected ? ( - - - - ) : ( - - - - )} - - ); - return ( -
-
-
- -
-
- ); - }, -); - -export const NodeOutputHandler = memo( - React.forwardRef( - function NodeOutputHandler(props, ref) { - const { - className, - addButtonClassName, - showAddButton, - nodeType, - handlerId, - onClick, - children, - ...rest - } = props; - - const nodeHandleId = `${nodeType}__${handlerId}`; - - return ( - - {showAddButton && ( - - )} - {children} - - ); - }, - ), -); diff --git a/frontend/core-ui/src/modules/automations/components/builder/nodes/TriggerNode.tsx b/frontend/core-ui/src/modules/automations/components/builder/nodes/TriggerNode.tsx deleted file mode 100644 index fe6d0779e5..0000000000 --- a/frontend/core-ui/src/modules/automations/components/builder/nodes/TriggerNode.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { NodeOutputHandler } from '@/automations/components/builder/nodes/NodeOutputHandler'; -import { NodeData } from '@/automations/types'; -import { IconAdjustmentsAlt } from '@tabler/icons-react'; -import { Node, NodeProps } from '@xyflow/react'; -import { cn, IconComponent } from 'erxes-ui'; -import { memo } from 'react'; -import { NodeDropdownActions } from './NodeDropdownActions'; -import { TriggerNodeConfigurationContent } from './TriggerNodeConfigurationContent'; -import { ErrorState } from '@/automations/utils/ErrorState'; - -const TriggerNodeContent = ({ data }: { data: NodeData }) => { - if (data?.error) { - return ( - - ); - } - - if (!data?.isCustom) { - return null; - } - - if (!Object.keys(data?.config || {}).length) { - return null; - } - - return ( -
-
- -

Configuration

-
-
- -
-
- ); -}; - -const TriggerNode = ({ data, selected, id }: NodeProps>) => { - const { beforeTitleContent, actionId } = data; - - return ( -
-
-

Trigger

-
-
-
-
- {beforeTitleContent && beforeTitleContent(id, 'trigger')} -
- -
-
-

{data.label}

-
-
- -
- -
-
-
- - {data.description} - - - -
- - -
-
- ); -}; - -export default memo(TriggerNode); diff --git a/frontend/core-ui/src/modules/automations/components/builder/nodes/TriggerNodeConfigurationContent.tsx b/frontend/core-ui/src/modules/automations/components/builder/nodes/TriggerNodeConfigurationContent.tsx deleted file mode 100644 index d27dcf3744..0000000000 --- a/frontend/core-ui/src/modules/automations/components/builder/nodes/TriggerNodeConfigurationContent.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { RenderPluginsComponentWrapper } from '@/automations/utils/RenderPluginsComponentWrapper'; -import { getAutomationTypes } from 'ui-modules'; - -export const TriggerNodeConfigurationContent = ({ - type, - config, -}: { - type: string; - config: any; -}) => { - const [pluginName, moduleName] = getAutomationTypes(type || ''); - - return ( - - ); -}; diff --git a/frontend/core-ui/src/modules/automations/components/builder/nodes/actions/CoreActions.ts b/frontend/core-ui/src/modules/automations/components/builder/nodes/actions/CoreActions.ts deleted file mode 100644 index b5619749f2..0000000000 --- a/frontend/core-ui/src/modules/automations/components/builder/nodes/actions/CoreActions.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { lazy } from 'react'; - -export const coreActions = { - delay: lazy(() => - import('./delay/components/Delay').then((module) => ({ - default: module.Delay.NodeContent, - })), - ), - setProperty: lazy(() => - import('./manageProperties/component/ManageProperties').then((module) => ({ - default: module.ManageProperties.NodeContent, - })), - ), - sendEmail: lazy(() => - import('./sendEmail/components/SendEmail').then((module) => ({ - default: module.SendEmail.NodeContent, - })), - ), -}; - -export const coreActionNames = Object.keys(coreActions); diff --git a/frontend/core-ui/src/modules/automations/components/builder/nodes/actions/aiAgent/components/AiAgent.ts b/frontend/core-ui/src/modules/automations/components/builder/nodes/actions/aiAgent/components/AiAgent.ts new file mode 100644 index 0000000000..536b66705c --- /dev/null +++ b/frontend/core-ui/src/modules/automations/components/builder/nodes/actions/aiAgent/components/AiAgent.ts @@ -0,0 +1,26 @@ +import { + AutomationComponentMap, + AutomationNodeType, +} from '@/automations/types'; +import { lazy } from 'react'; + +const AiAgentComponents: AutomationComponentMap = { + aiAgent: { + sidebar: lazy(() => + import( + '@/automations/components/builder/nodes/actions/aiAgent/components/AiAgentConfigForm' + ).then((module) => ({ + default: module.AIAgentConfigForm, + })), + ), + nodeContent: lazy(() => + import( + '@/automations/components/builder/nodes/actions/aiAgent/components/AiAgentNodeContent' + ).then((module) => ({ + default: module.AiAgentNodeContent, + })), + ), + }, +}; + +export default AiAgentComponents; diff --git a/frontend/core-ui/src/modules/automations/components/builder/nodes/actions/aiAgent/components/AiAgentConfigForm.tsx b/frontend/core-ui/src/modules/automations/components/builder/nodes/actions/aiAgent/components/AiAgentConfigForm.tsx new file mode 100644 index 0000000000..bdc46b85fd --- /dev/null +++ b/frontend/core-ui/src/modules/automations/components/builder/nodes/actions/aiAgent/components/AiAgentConfigForm.tsx @@ -0,0 +1,114 @@ +import { AiAgentObjectBuilder } from '@/automations/components/builder/nodes/actions/aiAgent/components/AiAgentObjectBuilder'; +import { AiAgentTopicBuilder } from '@/automations/components/builder/nodes/actions/aiAgent/components/AiAgentTopicBuilder'; +import { AI_AGENT_NODE_GOAL_TYPES } from '@/automations/components/builder/nodes/actions/aiAgent/constants/aiAgentConfigForm'; +import { + aiAgentConfigFormSchema, + TAiAgentConfigForm, +} from '@/automations/components/builder/nodes/actions/aiAgent/states/aiAgentForm'; +import { AutomationCoreConfigFormWrapper } from '@/automations/components/builder/nodes/components/AutomationConfigFormWrapper'; +import { useAiAgents } from '@/automations/components/settings/components/agents/hooks/useAiAgents'; +import { useFormValidationErrorHandler } from '@/automations/hooks/useFormValidationErrorHandler'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { IconPlus } from '@tabler/icons-react'; +import { Button, Command, Form, Select, Textarea } from 'erxes-ui'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; +import { Link } from 'react-router'; +import { TAutomationActionProps } from 'ui-modules'; + +export const AIAgentConfigForm = ({ + currentAction, + handleSave, +}: TAutomationActionProps) => { + const { handleValidationErrors } = useFormValidationErrorHandler({ + formName: 'Ai agent node Configuration', + }); + const form = useForm({ + resolver: zodResolver(aiAgentConfigFormSchema), + defaultValues: { ...(currentAction?.config || {}) }, + }); + const { automationsAiAgents } = useAiAgents(); + + const { control, handleSubmit } = form; + const config = useWatch({ + control, + }); + + return ( + + + { + return ( + + Ai Agent + + + + + ); + }} + /> + + { + return ( + + Goal Type + + + + ); + }} + /> + + {config?.goalType === 'generateObject' && } + {config?.goalType === 'classifyTopic' && } + {config?.goalType === 'generateText' && ( + ( + +