diff --git a/app/javascript/dashboard/api/captain/workflows.js b/app/javascript/dashboard/api/captain/workflows.js new file mode 100644 index 000000000..9d9fc4a80 --- /dev/null +++ b/app/javascript/dashboard/api/captain/workflows.js @@ -0,0 +1,40 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainWorkflows extends ApiClient { + constructor() { + super('captain/assistants', { accountScoped: true }); + } + + get({ assistantId, page = 1 } = {}) { + return axios.get(`${this.url}/${assistantId}/workflows`, { + params: { page }, + }); + } + + show({ assistantId, id }) { + return axios.get(`${this.url}/${assistantId}/workflows/${id}`); + } + + create({ assistantId, ...data } = {}) { + return axios.post(`${this.url}/${assistantId}/workflows`, { + workflow: data, + }); + } + + update({ assistantId, id }, data = {}) { + return axios.put(`${this.url}/${assistantId}/workflows/${id}`, { + workflow: data, + }); + } + + delete({ assistantId, id }) { + return axios.delete(`${this.url}/${assistantId}/workflows/${id}`); + } + + executions({ assistantId, id }) { + return axios.get(`${this.url}/${assistantId}/workflows/${id}/executions`); + } +} + +export default new CaptainWorkflows(); diff --git a/app/javascript/dashboard/components-next/captain/workflows/NodeConfigPanel.vue b/app/javascript/dashboard/components-next/captain/workflows/NodeConfigPanel.vue new file mode 100644 index 000000000..48ffc80c4 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/workflows/NodeConfigPanel.vue @@ -0,0 +1,159 @@ + + + + + + + {{ t('CAPTAIN.ASSISTANTS.WORKFLOWS.CONFIG.TITLE') }} + + + + + + + + {{ t('CAPTAIN.ASSISTANTS.WORKFLOWS.CONFIG.LABEL_FIELD') }} + + + + + + + {{ t(field.label) }} + + + + + + + + + + + + diff --git a/app/javascript/dashboard/components-next/captain/workflows/NodePalette.vue b/app/javascript/dashboard/components-next/captain/workflows/NodePalette.vue new file mode 100644 index 000000000..a192af8e4 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/workflows/NodePalette.vue @@ -0,0 +1,123 @@ + + + + + + + + + + {{ t(category.label) }} + + + + onDragStart(e, node.type)" + > + {{ t(node.label) }} + + + + + + diff --git a/app/javascript/dashboard/components-next/captain/workflows/WorkflowToolbar.vue b/app/javascript/dashboard/components-next/captain/workflows/WorkflowToolbar.vue new file mode 100644 index 000000000..01dbafbaf --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/workflows/WorkflowToolbar.vue @@ -0,0 +1,93 @@ + + + + + + + + + + + {{ t('CAPTAIN.ASSISTANTS.WORKFLOWS.TOOLBAR.ENABLED') }} + + + + + + + diff --git a/app/javascript/dashboard/components-next/captain/workflows/nodes/ActionNode.vue b/app/javascript/dashboard/components-next/captain/workflows/nodes/ActionNode.vue new file mode 100644 index 000000000..c6f891b44 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/workflows/nodes/ActionNode.vue @@ -0,0 +1,31 @@ + + + + + + + + + + + {{ data.label || 'Action' }} + + + + {{ data.description }} + + + + + diff --git a/app/javascript/dashboard/components-next/captain/workflows/nodes/ConditionNode.vue b/app/javascript/dashboard/components-next/captain/workflows/nodes/ConditionNode.vue new file mode 100644 index 000000000..3a376c82b --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/workflows/nodes/ConditionNode.vue @@ -0,0 +1,47 @@ + + + + + + + + + + + {{ data.label || 'Condition' }} + + + + + {{ t('CAPTAIN.ASSISTANTS.WORKFLOWS.CONFIG.CONDITION_TRUE') }} + + + {{ t('CAPTAIN.ASSISTANTS.WORKFLOWS.CONFIG.CONDITION_FALSE') }} + + + + + + + diff --git a/app/javascript/dashboard/components-next/captain/workflows/nodes/ShopifyNode.vue b/app/javascript/dashboard/components-next/captain/workflows/nodes/ShopifyNode.vue new file mode 100644 index 000000000..de09a0d09 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/workflows/nodes/ShopifyNode.vue @@ -0,0 +1,28 @@ + + + + + + + + + + + {{ data.label || 'Shopify' }} + + + + {{ data.description || 'Shopify integration' }} + + + + + diff --git a/app/javascript/dashboard/components-next/captain/workflows/nodes/TriggerNode.vue b/app/javascript/dashboard/components-next/captain/workflows/nodes/TriggerNode.vue new file mode 100644 index 000000000..bedd98c0a --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/workflows/nodes/TriggerNode.vue @@ -0,0 +1,27 @@ + + + + + + + + + + {{ data.label || 'Trigger' }} + + + + {{ data.event || 'Starts the workflow' }} + + + + + diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index d08147739..51bf5733b 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -348,6 +348,17 @@ const menuItems = computed(() => { navigationPath: 'captain_assistants_scenarios_index', }), }, + { + name: 'Workflows', + label: t('SIDEBAR.CAPTAIN_WORKFLOWS'), + activeOn: [ + 'captain_assistants_workflows_index', + 'captain_assistants_workflow_editor', + ], + to: accountScopedRoute('captain_assistants_index', { + navigationPath: 'captain_assistants_workflows_index', + }), + }, { name: 'Playground', label: t('SIDEBAR.CAPTAIN_PLAYGROUND'), diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index c8e488959..f51cedb7f 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -732,6 +732,74 @@ "ERROR": "There was an error deleting scenarios, please try again." } } + }, + "WORKFLOWS": { + "TITLE": "Workflows", + "DESCRIPTION": "Automate actions triggered by conversation events with a visual workflow builder.", + "ADD_BUTTON": "New Workflow", + "DEFAULT_NAME": "New Workflow", + "EMPTY_MESSAGE": "No workflows yet. Create one to automate conversation actions.", + "TRIGGERS": { + "CONVERSATION_CREATED": "Conversation Created", + "MESSAGE_CREATED": "Message Created", + "CONVERSATION_RESOLVED": "Conversation Resolved" + }, + "PALETTE": { + "TRIGGERS": "Triggers", + "LOGIC": "Logic", + "ACTIONS": "Actions", + "SHOPIFY": "Shopify" + }, + "NODES": { + "TRIGGER_CONVERSATION_CREATED": "Conversation Created", + "TRIGGER_MESSAGE_CREATED": "Message Created", + "TRIGGER_CONVERSATION_RESOLVED": "Conversation Resolved", + "CONDITION": "Condition", + "SEND_MESSAGE": "Send Message", + "ADD_LABEL": "Add Label", + "ASSIGN_AGENT": "Assign Agent", + "ASSIGN_TEAM": "Assign Team", + "RESOLVE_CONVERSATION": "Resolve Conversation", + "ADD_PRIVATE_NOTE": "Add Private Note", + "UPDATE_PRIORITY": "Update Priority", + "SHOPIFY_SEARCH_CUSTOMER": "Search Customer", + "SHOPIFY_GET_CUSTOMER_ORDERS": "Get Customer Orders" + }, + "CONFIG": { + "TITLE": "Node Configuration", + "LABEL_FIELD": "Label", + "MESSAGE": "Message", + "LABEL": "Label", + "AGENT_ID": "Agent ID", + "TEAM_ID": "Team ID", + "PRIORITY": "Priority", + "ATTRIBUTE": "Attribute", + "OPERATOR": "Operator", + "VALUE": "Value", + "APPLY": "Apply", + "DELETE": "Delete Node", + "CONDITION_TRUE": "True", + "CONDITION_FALSE": "False" + }, + "TOOLBAR": { + "NAME_PLACEHOLDER": "Workflow name", + "ENABLED": "Enabled", + "SAVE": "Save" + }, + "API": { + "CREATE": { + "SUCCESS": "Workflow created successfully", + "ERROR": "There was an error creating the workflow, please try again." + }, + "UPDATE": { + "SUCCESS": "Workflow updated successfully", + "ERROR": "There was an error updating the workflow, please try again." + }, + "DELETE": { + "SUCCESS": "Workflow deleted successfully", + "ERROR": "There was an error deleting the workflow, please try again." + } + } } }, "DOCUMENTS": { diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index d885cf8ce..fbdd09faa 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -316,6 +316,7 @@ "CAPTAIN_RESPONSES": "FAQs", "CAPTAIN_TOOLS": "Tools", "CAPTAIN_SCENARIOS": "Scenarios", + "CAPTAIN_WORKFLOWS": "Workflows", "CAPTAIN_PLAYGROUND": "Playground", "CAPTAIN_INBOXES": "Inboxes", "CAPTAIN_SETTINGS": "Settings", diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/workflows/Editor.vue b/app/javascript/dashboard/routes/dashboard/captain/assistants/workflows/Editor.vue new file mode 100644 index 000000000..07a1ea87e --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/workflows/Editor.vue @@ -0,0 +1,194 @@ + + + + + + + + + + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/workflows/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/assistants/workflows/Index.vue new file mode 100644 index 000000000..026747bb0 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/workflows/Index.vue @@ -0,0 +1,170 @@ + + + + + + + + + + + + + {{ $t('CAPTAIN.ASSISTANTS.WORKFLOWS.EMPTY_MESSAGE') }} + + + + + + + {{ workflow.name }} + + + {{ workflow.description }} + + + {{ $t(TRIGGER_LABELS[workflow.trigger_event]) }} + + + + + + + + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js b/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js index 4bc11ba1c..3373c59cf 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js +++ b/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js @@ -12,6 +12,8 @@ import AssistantPlaygroundIndex from './assistants/playground/Index.vue'; import AssistantGuardrailsIndex from './assistants/guardrails/Index.vue'; import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue'; import AssistantScenariosIndex from './assistants/scenarios/Index.vue'; +import AssistantWorkflowsIndex from './assistants/workflows/Index.vue'; +import AssistantWorkflowEditor from './assistants/workflows/Editor.vue'; import DocumentsIndex from './documents/Index.vue'; import ResponsesIndex from './responses/Index.vue'; import ResponsesPendingIndex from './responses/Pending.vue'; @@ -54,6 +56,20 @@ const assistantRoutes = [ name: 'captain_assistants_scenarios_index', meta: metaV2, }, + { + path: frontendURL('accounts/:accountId/captain/:assistantId/workflows'), + component: AssistantWorkflowsIndex, + name: 'captain_assistants_workflows_index', + meta, + }, + { + path: frontendURL( + 'accounts/:accountId/captain/:assistantId/workflows/:workflowId' + ), + component: AssistantWorkflowEditor, + name: 'captain_assistants_workflow_editor', + meta, + }, { path: frontendURL('accounts/:accountId/captain/:assistantId/playground'), component: AssistantPlaygroundIndex, diff --git a/app/javascript/dashboard/routes/dashboard/captain/pages/AssistantsIndexPage.vue b/app/javascript/dashboard/routes/dashboard/captain/pages/AssistantsIndexPage.vue index 01ec64618..ffd76131a 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/pages/AssistantsIndexPage.vue +++ b/app/javascript/dashboard/routes/dashboard/captain/pages/AssistantsIndexPage.vue @@ -56,6 +56,7 @@ const routeToLastActiveAssistant = () => { 'captain_assistants_responses_index', // Faq page 'captain_assistants_documents_index', // Document page 'captain_assistants_scenarios_index', // Scenario page + 'captain_assistants_workflows_index', // Workflows page 'captain_assistants_playground_index', // Playground page 'captain_assistants_inboxes_index', // Inboxes page 'captain_tools_index', // Tools page diff --git a/app/javascript/dashboard/store/captain/workflows.js b/app/javascript/dashboard/store/captain/workflows.js new file mode 100644 index 000000000..91dc22234 --- /dev/null +++ b/app/javascript/dashboard/store/captain/workflows.js @@ -0,0 +1,81 @@ +import CaptainWorkflows from 'dashboard/api/captain/workflows'; +import { createStore } from '../storeFactory'; +import { throwErrorMessage } from 'dashboard/store/utils/api'; + +export default createStore({ + name: 'CaptainWorkflow', + API: CaptainWorkflows, + actions: mutations => ({ + get: async ({ commit }, { assistantId, page = 1 } = {}) => { + commit(mutations.SET_UI_FLAG, { fetchingList: true }); + try { + const response = await CaptainWorkflows.get({ assistantId, page }); + commit(mutations.SET, response.data.payload); + commit(mutations.SET_META, response.data.meta); + commit(mutations.SET_UI_FLAG, { fetchingList: false }); + return response.data; + } catch (error) { + commit(mutations.SET_UI_FLAG, { fetchingList: false }); + return throwErrorMessage(error); + } + }, + + show: async ({ commit }, { assistantId, id }) => { + commit(mutations.SET_UI_FLAG, { fetchingItem: true }); + try { + const response = await CaptainWorkflows.show({ assistantId, id }); + commit(mutations.ADD, response.data); + commit(mutations.SET_UI_FLAG, { fetchingItem: false }); + return response.data; + } catch (error) { + commit(mutations.SET_UI_FLAG, { fetchingItem: false }); + return throwErrorMessage(error); + } + }, + + create: async ({ commit }, { assistantId, ...data }) => { + commit(mutations.SET_UI_FLAG, { creatingItem: true }); + try { + const response = await CaptainWorkflows.create({ + assistantId, + ...data, + }); + commit(mutations.ADD, response.data); + commit(mutations.SET_UI_FLAG, { creatingItem: false }); + return response.data; + } catch (error) { + commit(mutations.SET_UI_FLAG, { creatingItem: false }); + return throwErrorMessage(error); + } + }, + + update: async ({ commit }, { id, assistantId, ...updateObj }) => { + commit(mutations.SET_UI_FLAG, { updatingItem: true }); + try { + const response = await CaptainWorkflows.update( + { id, assistantId }, + updateObj + ); + commit(mutations.EDIT, response.data); + commit(mutations.SET_UI_FLAG, { updatingItem: false }); + return response.data; + } catch (error) { + commit(mutations.SET_UI_FLAG, { updatingItem: false }); + return throwErrorMessage(error); + } + }, + + delete: async ({ commit }, { id, assistantId }) => { + commit(mutations.SET_UI_FLAG, { deletingItem: true }); + try { + await CaptainWorkflows.delete({ id, assistantId }); + commit(mutations.DELETE, id); + commit(mutations.SET_UI_FLAG, { deletingItem: false }); + return id; + } catch (error) { + commit(mutations.SET_UI_FLAG, { deletingItem: false }); + return throwErrorMessage(error); + } + }, + }), +}); diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js index d56958eb5..fa29126bb 100755 --- a/app/javascript/dashboard/store/index.js +++ b/app/javascript/dashboard/store/index.js @@ -58,6 +58,7 @@ import copilotMessages from './captain/copilotMessages'; import captainScenarios from './captain/scenarios'; import captainTools from './captain/tools'; import captainCustomTools from './captain/customTools'; +import captainWorkflows from './captain/workflows'; const plugins = []; @@ -121,6 +122,7 @@ export default createStore({ captainScenarios, captainTools, captainCustomTools, + captainWorkflows, }, plugins, }); diff --git a/config/routes.rb b/config/routes.rb index 1efd9f526..4964f2f0d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -66,6 +66,11 @@ Rails.application.routes.draw do end resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id resources :scenarios + resources :workflows do + member do + get :executions + end + end end resources :assistant_responses resources :bulk_actions, only: [:create] diff --git a/db/migrate/20260227100001_create_captain_workflows.rb b/db/migrate/20260227100001_create_captain_workflows.rb new file mode 100644 index 000000000..e4e416c2e --- /dev/null +++ b/db/migrate/20260227100001_create_captain_workflows.rb @@ -0,0 +1,19 @@ +class CreateCaptainWorkflows < ActiveRecord::Migration[7.1] + def change + create_table :captain_workflows do |t| + t.string :name, null: false + t.text :description + t.references :account, null: false + t.references :assistant, null: false + t.string :trigger_event, null: false + t.jsonb :trigger_conditions, default: {} + t.jsonb :nodes, default: [] + t.jsonb :edges, default: [] + t.boolean :enabled, default: false, null: false + + t.timestamps + end + + add_index :captain_workflows, [:account_id, :trigger_event, :enabled], name: 'index_captain_workflows_on_account_event_enabled' + end +end diff --git a/db/migrate/20260227100002_create_captain_workflow_executions.rb b/db/migrate/20260227100002_create_captain_workflow_executions.rb new file mode 100644 index 000000000..970bd832e --- /dev/null +++ b/db/migrate/20260227100002_create_captain_workflow_executions.rb @@ -0,0 +1,17 @@ +class CreateCaptainWorkflowExecutions < ActiveRecord::Migration[7.1] + def change + create_table :captain_workflow_executions do |t| + t.references :workflow, null: false + t.references :account, null: false + t.references :conversation + t.references :contact + t.integer :status, default: 0, null: false + t.datetime :started_at + t.datetime :completed_at + t.text :error_message + t.jsonb :execution_log, default: [] + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 8a450e734..4629ddd57 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do +ActiveRecord::Schema[7.1].define(version: 2026_02_27_100002) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -28,6 +28,32 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do t.index ["token"], name: "index_access_tokens_on_token", unique: true end + create_table "account_hourly_reports", force: :cascade do |t| + t.bigint "account_id", null: false + t.datetime "hour", null: false + t.string "scope_type", default: "account", null: false + t.bigint "scope_id" + t.integer "conversations_count", default: 0, null: false + t.integer "incoming_messages_count", default: 0, null: false + t.integer "outgoing_messages_count", default: 0, null: false + t.integer "resolutions_count", default: 0, null: false + t.integer "bot_resolutions_count", default: 0, null: false + t.integer "bot_handoffs_count", default: 0, null: false + t.float "first_response_time_sum", default: 0.0, null: false + t.integer "first_response_time_count", default: 0, null: false + t.float "resolution_time_sum", default: 0.0, null: false + t.integer "resolution_time_count", default: 0, null: false + t.float "reply_time_sum", default: 0.0, null: false + t.integer "reply_time_count", default: 0, null: false + t.float "first_response_time_bh_sum", default: 0.0, null: false + t.float "resolution_time_bh_sum", default: 0.0, null: false + t.float "reply_time_bh_sum", default: 0.0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "hour", "scope_type", "scope_id"], name: "idx_account_hourly_reports_unique", unique: true + t.index ["account_id", "scope_type", "scope_id", "hour"], name: "idx_account_hourly_reports_lookup" + end + create_table "account_saml_settings", force: :cascade do |t| t.bigint "account_id", null: false t.string "sso_url" @@ -71,6 +97,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do t.jsonb "limits", default: {} t.jsonb "custom_attributes", default: {} t.integer "status", default: 0 + t.integer "contactable_contacts_count", default: 0 t.jsonb "internal_attributes", default: {}, null: false t.jsonb "settings", default: {} t.index ["status"], name: "index_accounts_on_status" @@ -384,6 +411,41 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do t.index ["enabled"], name: "index_captain_scenarios_on_enabled" end + create_table "captain_workflow_executions", force: :cascade do |t| + t.bigint "workflow_id", null: false + t.bigint "account_id", null: false + t.bigint "conversation_id" + t.bigint "contact_id" + t.integer "status", default: 0, null: false + t.datetime "started_at" + t.datetime "completed_at" + t.text "error_message" + t.jsonb "execution_log", default: [] + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_captain_workflow_executions_on_account_id" + t.index ["contact_id"], name: "index_captain_workflow_executions_on_contact_id" + t.index ["conversation_id"], name: "index_captain_workflow_executions_on_conversation_id" + t.index ["workflow_id"], name: "index_captain_workflow_executions_on_workflow_id" + end + + create_table "captain_workflows", force: :cascade do |t| + t.string "name", null: false + t.text "description" + t.bigint "account_id", null: false + t.bigint "assistant_id", null: false + t.string "trigger_event", null: false + t.jsonb "trigger_conditions", default: {} + t.jsonb "nodes", default: [] + t.jsonb "edges", default: [] + t.boolean "enabled", default: false, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "trigger_event", "enabled"], name: "index_captain_workflows_on_account_event_enabled" + t.index ["account_id"], name: "index_captain_workflows_on_account_id" + t.index ["assistant_id"], name: "index_captain_workflows_on_assistant_id" + end + create_table "categories", force: :cascade do |t| t.integer "account_id", null: false t.integer "portal_id", null: false @@ -589,13 +651,13 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do t.bigint "account_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.integer "contacts_count" + t.integer "contacts_count", default: 0, null: false t.index ["account_id", "domain"], name: "index_companies_on_account_and_domain", unique: true, where: "(domain IS NOT NULL)" t.index ["account_id"], name: "index_companies_on_account_id" t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id" end - create_table "contact_inboxes", force: :cascade do |t| + create_table "contact_inboxes", id: :bigint, default: -> { "nextval('contact_inboxes2_id_seq'::regclass)" }, force: :cascade do |t| t.bigint "contact_id" t.bigint "inbox_id" t.text "source_id", null: false @@ -603,14 +665,14 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do t.datetime "updated_at", null: false t.boolean "hmac_verified", default: false t.string "pubsub_token" - t.index ["contact_id"], name: "index_contact_inboxes_on_contact_id" - t.index ["inbox_id", "source_id"], name: "index_contact_inboxes_on_inbox_id_and_source_id", unique: true - t.index ["inbox_id"], name: "index_contact_inboxes_on_inbox_id" - t.index ["pubsub_token"], name: "index_contact_inboxes_on_pubsub_token", unique: true - t.index ["source_id"], name: "index_contact_inboxes_on_source_id" + t.index ["contact_id"], name: "contact_inboxes2_contact_id_idx" + t.index ["inbox_id", "source_id"], name: "contact_inboxes2_inbox_id_source_id_idx", unique: true + t.index ["inbox_id"], name: "contact_inboxes2_inbox_id_idx" + t.index ["pubsub_token"], name: "contact_inboxes2_pubsub_token_idx", unique: true + t.index ["source_id"], name: "contact_inboxes2_source_id_idx" end - create_table "contacts", id: :serial, force: :cascade do |t| + create_table "contacts", id: :integer, default: -> { "nextval('contacts2_id_seq'::regclass)" }, force: :cascade do |t| t.string "name", default: "" t.string "email" t.string "phone_number" @@ -621,25 +683,27 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do t.string "identifier" t.jsonb "custom_attributes", default: {} t.datetime "last_activity_at", precision: nil - t.integer "contact_type", default: 0 + t.boolean "resolved", default: false, null: false + t.integer "contact_type", default: 0, null: false t.string "middle_name", default: "" t.string "last_name", default: "" t.string "location", default: "" t.string "country_code", default: "" t.boolean "blocked", default: false, null: false t.bigint "company_id" - t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id" + t.index "lower((email)::text), account_id", name: "contacts2_lower_account_id_idx" t.index ["account_id", "contact_type"], name: "index_contacts_on_account_id_and_contact_type" - t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))" + t.index ["account_id", "email", "phone_number", "identifier"], name: "contacts2_account_id_email_phone_number_identifier_idx", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))" t.index ["account_id", "last_activity_at"], name: "index_contacts_on_account_id_and_last_activity_at", order: { last_activity_at: "DESC NULLS LAST" } - t.index ["account_id"], name: "index_contacts_on_account_id" - t.index ["account_id"], name: "index_resolved_contact_account_id", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))" + t.index ["account_id", "resolved"], name: "index_contacts_on_account_id_and_resolved" + t.index ["account_id"], name: "contacts2_account_id_idx" + t.index ["account_id"], name: "contacts2_account_id_idx1", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))" t.index ["blocked"], name: "index_contacts_on_blocked" t.index ["company_id"], name: "index_contacts_on_company_id" - t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true - t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true - t.index ["name", "email", "phone_number", "identifier"], name: "index_contacts_on_name_email_phone_number_identifier", opclass: :gin_trgm_ops, using: :gin - t.index ["phone_number", "account_id"], name: "index_contacts_on_phone_number_and_account_id" + t.index ["email", "account_id"], name: "contacts2_email_account_id_idx", unique: true + t.index ["identifier", "account_id"], name: "contacts2_identifier_account_id_idx", unique: true + t.index ["name", "email", "phone_number", "identifier"], name: "contacts2_name_email_phone_number_identifier_idx", opclass: :gin_trgm_ops, using: :gin + t.index ["phone_number", "account_id"], name: "contacts2_phone_number_account_id_idx" end create_table "conversation_participants", force: :cascade do |t| @@ -667,7 +731,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do t.datetime "agent_last_seen_at", precision: nil t.jsonb "additional_attributes", default: {} t.bigint "contact_inbox_id" - t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false + t.uuid "uuid", default: -> { "public.gen_random_uuid()" }, null: false t.string "identifier" t.datetime "last_activity_at", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false t.bigint "team_id" @@ -681,6 +745,12 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do t.datetime "waiting_since" t.text "cached_label_list" t.bigint "assignee_agent_bot_id" + t.bigint "last_message_id" + t.bigint "last_incoming_message_id" + t.bigint "last_non_activity_message_id" + t.text "cached_summary" + t.datetime "cached_summary_at" + t.index ["account_id", "created_at", "inbox_id"], name: "index_conversations_on_account_created_inbox" t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id" t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx" @@ -692,6 +762,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do t.index ["first_reply_created_at"], name: "index_conversations_on_first_reply_created_at" t.index ["identifier", "account_id"], name: "index_conversations_on_identifier_and_account_id" t.index ["inbox_id"], name: "index_conversations_on_inbox_id" + t.index ["last_incoming_message_id"], name: "index_conversations_on_last_incoming_message_id" + t.index ["last_message_id"], name: "index_conversations_on_last_message_id" + t.index ["last_non_activity_message_id"], name: "index_conversations_on_last_non_activity_message_id" t.index ["priority"], name: "index_conversations_on_priority" t.index ["status", "account_id"], name: "index_conversations_on_status_and_account_id" t.index ["status", "priority"], name: "index_conversations_on_status_and_priority" @@ -758,7 +831,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do t.string "regex_pattern" t.string "regex_cue" t.index ["account_id"], name: "index_custom_attribute_definitions_on_account_id" - t.index ["attribute_key", "attribute_model", "account_id"], name: "attribute_key_model_index", unique: true end create_table "custom_filters", force: :cascade do |t| @@ -941,7 +1013,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do t.integer "visibility", default: 0 t.bigint "created_by_id" t.bigint "updated_by_id" - t.jsonb "actions", default: {}, null: false + t.jsonb "actions", default: "{}", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_macros_on_account_id" @@ -960,7 +1032,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do t.index ["user_id"], name: "index_mentions_on_user_id" end - create_table "messages", id: :serial, force: :cascade do |t| + create_table "messages", id: :integer, default: -> { "nextval('messages2_id_seq'::regclass)" }, force: :cascade do |t| t.text "content" t.integer "account_id", null: false t.integer "inbox_id", null: false @@ -979,18 +1051,18 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do t.jsonb "additional_attributes", default: {} t.text "processed_message_content" t.jsonb "sentiment", default: {} - t.index "((additional_attributes -> 'campaign_id'::text))", name: "index_messages_on_additional_attributes_campaign_id", using: :gin + t.index "((additional_attributes -> 'campaign_id'::text))", name: "messages2_expr_idx", using: :gin t.index ["account_id", "content_type", "created_at"], name: "idx_messages_account_content_created" t.index ["account_id", "created_at", "message_type"], name: "index_messages_on_account_created_type" - t.index ["account_id", "inbox_id"], name: "index_messages_on_account_id_and_inbox_id" - t.index ["account_id"], name: "index_messages_on_account_id" - t.index ["content"], name: "index_messages_on_content", opclass: :gin_trgm_ops, using: :gin - t.index ["conversation_id", "account_id", "message_type", "created_at"], name: "index_messages_on_conversation_account_type_created" - t.index ["conversation_id"], name: "index_messages_on_conversation_id" - t.index ["created_at"], name: "index_messages_on_created_at" - t.index ["inbox_id"], name: "index_messages_on_inbox_id" - t.index ["sender_type", "sender_id"], name: "index_messages_on_sender_type_and_sender_id" - t.index ["source_id"], name: "index_messages_on_source_id" + t.index ["account_id", "inbox_id"], name: "messages2_account_id_inbox_id_idx" + t.index ["account_id"], name: "messages2_account_id_idx" + t.index ["content"], name: "messages2_content_idx", opclass: :gin_trgm_ops, using: :gin + t.index ["conversation_id", "account_id", "message_type", "created_at"], name: "messages2_conversation_id_account_id_message_type_created_a_idx" + t.index ["conversation_id"], name: "messages2_conversation_id_idx" + t.index ["created_at"], name: "messages2_created_at_idx" + t.index ["inbox_id"], name: "messages2_inbox_id_idx" + t.index ["sender_type", "sender_id"], name: "messages2_sender_type_sender_id_idx" + t.index ["source_id"], name: "messages2_source_id_idx" end create_table "notes", force: :cascade do |t| @@ -1048,6 +1120,19 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do t.index ["user_id"], name: "index_notifications_on_user_id" end + create_table "pg_search_documents", force: :cascade do |t| + t.text "content" + t.bigint "conversation_id" + t.bigint "account_id" + t.string "searchable_type" + t.bigint "searchable_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_pg_search_documents_on_account_id" + t.index ["conversation_id"], name: "index_pg_search_documents_on_conversation_id" + t.index ["searchable_type", "searchable_id"], name: "index_pg_search_documents_on_searchable" + end + create_table "platform_app_permissibles", force: :cascade do |t| t.bigint "platform_app_id", null: false t.string "permissible_type", null: false @@ -1114,6 +1199,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do t.float "value_in_business_hours" t.datetime "event_start_time", precision: nil t.datetime "event_end_time", precision: nil + t.index ["account_id", "name", "created_at", "inbox_id"], name: "index_reporting_events_on_account_name_created_inbox" t.index ["account_id", "name", "created_at"], name: "reporting_events__account_id__name__created_at" t.index ["account_id", "name", "inbox_id", "created_at"], name: "index_reporting_events_for_response_distribution" t.index ["account_id"], name: "index_reporting_events_on_account_id" @@ -1231,7 +1317,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do t.text "message_signature" t.string "otp_secret" t.integer "consumed_timestep" - t.boolean "otp_required_for_login", default: false + t.boolean "otp_required_for_login", default: false, null: false t.text "otp_backup_codes" t.index ["email"], name: "index_users_on_email" t.index ["otp_required_for_login"], name: "index_users_on_otp_required_for_login" @@ -1270,6 +1356,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do t.index ["inbox_id"], name: "index_working_hours_on_inbox_id" end + add_foreign_key "account_hourly_reports", "accounts" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "inboxes", "portals" diff --git a/enterprise/app/controllers/api/v1/accounts/captain/workflows_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/workflows_controller.rb new file mode 100644 index 000000000..30a3ec35a --- /dev/null +++ b/enterprise/app/controllers/api/v1/accounts/captain/workflows_controller.rb @@ -0,0 +1,54 @@ +class Api::V1::Accounts::Captain::WorkflowsController < Api::V1::Accounts::BaseController + before_action :current_account + before_action -> { check_authorization(Captain::Workflow) } + before_action :set_assistant + before_action :set_workflow, only: [:show, :update, :destroy, :executions] + + def index + @workflows = assistant_workflows.order(created_at: :desc) + end + + def show; end + + def create + @workflow = assistant_workflows.create!(workflow_params.merge(account: Current.account)) + end + + def update + @workflow.update!(workflow_params) + end + + def destroy + @workflow.destroy + head :no_content + end + + def executions + @executions = @workflow.executions.order(created_at: :desc).limit(50) + end + + private + + def set_assistant + @assistant = account_assistants.find(params[:assistant_id]) + end + + def account_assistants + @account_assistants ||= Current.account.captain_assistants + end + + def set_workflow + @workflow = assistant_workflows.find(params[:id]) + end + + def assistant_workflows + @assistant.workflows + end + + def workflow_params + permitted = params.require(:workflow).permit(:name, :description, :trigger_event, :enabled, trigger_conditions: {}) + permitted[:nodes] = params[:workflow][:nodes] if params[:workflow][:nodes].present? + permitted[:edges] = params[:workflow][:edges] if params[:workflow][:edges].present? + permitted + end +end diff --git a/enterprise/app/jobs/captain/workflows/execution_job.rb b/enterprise/app/jobs/captain/workflows/execution_job.rb new file mode 100644 index 000000000..75613bc6a --- /dev/null +++ b/enterprise/app/jobs/captain/workflows/execution_job.rb @@ -0,0 +1,7 @@ +class Captain::Workflows::ExecutionJob < ApplicationJob + queue_as :default + + def perform(workflow, context) + Captain::Workflows::ExecutionService.new(workflow, context).perform + end +end diff --git a/enterprise/app/listeners/captain_listener.rb b/enterprise/app/listeners/captain_listener.rb index 879fb2b7e..452e96d64 100644 --- a/enterprise/app/listeners/captain_listener.rb +++ b/enterprise/app/listeners/captain_listener.rb @@ -1,6 +1,18 @@ class CaptainListener < BaseListener include ::Events::Types + def conversation_created(event) + conversation = extract_conversation_and_account(event)[0] + trigger_workflows(conversation, 'conversation_created') + end + + def message_created(event) + message = extract_message_and_account(event)[0] + return unless message.conversation + + trigger_workflows(message.conversation, 'message_created', message: message) + end + def conversation_resolved(event) conversation = extract_conversation_and_account(event)[0] assistant = conversation.inbox.captain_assistant @@ -9,5 +21,28 @@ class CaptainListener < BaseListener Captain::Llm::ContactNotesService.new(assistant, conversation).generate_and_update_notes if assistant.config['feature_memory'].present? Captain::Llm::ConversationFaqService.new(assistant, conversation).generate_and_deduplicate if assistant.config['feature_faq'].present? + + trigger_workflows(conversation, 'conversation_resolved') + end + + private + + def trigger_workflows(conversation, event, extra_context = {}) + return unless conversation.inbox.captain_active? + + assistant = conversation.inbox.captain_assistant + return unless assistant + + workflows = assistant.workflows.enabled.for_event(event) + workflows.each do |workflow| + context = { + account: conversation.account, + conversation: conversation, + contact: conversation.contact, + event: event + }.merge(extra_context) + + Captain::Workflows::ExecutionJob.perform_later(workflow, context) + end end end diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb index 4f039d57a..e027a5b2b 100644 --- a/enterprise/app/models/captain/assistant.rb +++ b/enterprise/app/models/captain/assistant.rb @@ -35,6 +35,7 @@ class Captain::Assistant < ApplicationRecord has_many :messages, as: :sender, dependent: :nullify has_many :copilot_threads, dependent: :destroy_async has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async + has_many :workflows, class_name: 'Captain::Workflow', dependent: :destroy_async store_accessor :config, :temperature, :feature_faq, :feature_memory, :product_name diff --git a/enterprise/app/models/captain/workflow.rb b/enterprise/app/models/captain/workflow.rb new file mode 100644 index 000000000..0554d9436 --- /dev/null +++ b/enterprise/app/models/captain/workflow.rb @@ -0,0 +1,16 @@ +class Captain::Workflow < ApplicationRecord + self.table_name = 'captain_workflows' + + belongs_to :assistant, class_name: 'Captain::Assistant' + belongs_to :account + has_many :executions, class_name: 'Captain::WorkflowExecution', dependent: :destroy_async + + validates :name, presence: true + validates :trigger_event, presence: true + validates :assistant_id, presence: true + validates :account_id, presence: true + validates :trigger_event, inclusion: { in: %w[conversation_created message_created conversation_resolved] } + + scope :enabled, -> { where(enabled: true) } + scope :for_event, ->(event) { where(trigger_event: event) } +end diff --git a/enterprise/app/models/captain/workflow_execution.rb b/enterprise/app/models/captain/workflow_execution.rb new file mode 100644 index 000000000..9ecde31bd --- /dev/null +++ b/enterprise/app/models/captain/workflow_execution.rb @@ -0,0 +1,10 @@ +class Captain::WorkflowExecution < ApplicationRecord + self.table_name = 'captain_workflow_executions' + + belongs_to :workflow, class_name: 'Captain::Workflow' + belongs_to :account + belongs_to :conversation, optional: true + belongs_to :contact, optional: true + + enum :status, { pending: 0, running: 1, completed: 2, failed: 3 } +end diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb index 01693ac79..76dafe7c3 100644 --- a/enterprise/app/models/enterprise/concerns/account.rb +++ b/enterprise/app/models/enterprise/concerns/account.rb @@ -13,6 +13,7 @@ module Enterprise::Concerns::Account has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse' has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document' has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool' + has_many :captain_workflows, dependent: :destroy_async, class_name: 'Captain::Workflow' has_many :copilot_threads, dependent: :destroy_async has_many :companies, dependent: :destroy_async diff --git a/enterprise/app/policies/captain/workflow_policy.rb b/enterprise/app/policies/captain/workflow_policy.rb new file mode 100644 index 000000000..fb70dff08 --- /dev/null +++ b/enterprise/app/policies/captain/workflow_policy.rb @@ -0,0 +1,25 @@ +class Captain::WorkflowPolicy < ApplicationPolicy + def index? + true + end + + def show? + true + end + + def create? + @account_user.administrator? + end + + def update? + @account_user.administrator? + end + + def destroy? + @account_user.administrator? + end + + def executions? + true + end +end diff --git a/enterprise/app/services/captain/workflows/execution_service.rb b/enterprise/app/services/captain/workflows/execution_service.rb new file mode 100644 index 000000000..ffcf4f8f5 --- /dev/null +++ b/enterprise/app/services/captain/workflows/execution_service.rb @@ -0,0 +1,88 @@ +class Captain::Workflows::ExecutionService + attr_reader :workflow, :context, :execution + + def initialize(workflow, context) + @workflow = workflow + @context = context.with_indifferent_access + end + + def perform + @execution = create_execution + execution.update!(status: :running, started_at: Time.current) + + traverse_graph + execution.update!(status: :completed, completed_at: Time.current) + rescue StandardError => e + execution&.update!(status: :failed, completed_at: Time.current, error_message: e.message) + Rails.logger.error("[CaptainWorkflow] Workflow #{workflow.id} failed: #{e.message}") + end + + private + + def create_execution + Captain::WorkflowExecution.create!( + workflow: workflow, + account: workflow.account, + conversation_id: context[:conversation]&.id, + contact_id: context[:contact]&.id, + status: :pending, + execution_log: [] + ) + end + + def traverse_graph + nodes = workflow.nodes + edges = workflow.edges + return if nodes.blank? + + trigger_node = nodes.find { |n| n['type']&.start_with?('trigger_') } + return unless trigger_node + + visited = Set.new + execute_node(trigger_node, nodes, edges, visited) + end + + def execute_node(node, nodes, edges, visited) + node_id = node['id'] + return if visited.include?(node_id) + + visited.add(node_id) + + executor_class = Captain::Workflows::NodeRegistry.resolve(node['type']) + unless executor_class + log_step(node_id, node['type'], 'skipped', { reason: 'unknown node type' }) + return + end + + executor = executor_class.new(node, context) + result = executor.execute + log_step(node_id, node['type'], 'completed', result) + + next_edges = find_next_edges(node_id, edges, result) + next_edges.each do |edge| + target_node = nodes.find { |n| n['id'] == edge['target'] } + execute_node(target_node, nodes, edges, visited) if target_node + end + end + + def find_next_edges(node_id, edges, result) + outgoing = edges.select { |e| e['source'] == node_id } + + if result.is_a?(Hash) && result[:next_handle] + outgoing.select { |e| e['sourceHandle'] == result[:next_handle] } + else + outgoing + end + end + + def log_step(node_id, node_type, status, result) + execution.execution_log << { + node_id: node_id, + node_type: node_type, + status: status, + result: result, + timestamp: Time.current.iso8601 + } + execution.save! + end +end diff --git a/enterprise/app/services/captain/workflows/node_registry.rb b/enterprise/app/services/captain/workflows/node_registry.rb new file mode 100644 index 000000000..3a86cf19d --- /dev/null +++ b/enterprise/app/services/captain/workflows/node_registry.rb @@ -0,0 +1,21 @@ +class Captain::Workflows::NodeRegistry + REGISTRY = { + 'trigger_conversation_created' => Captain::Workflows::Nodes::TriggerNode, + 'trigger_message_created' => Captain::Workflows::Nodes::TriggerNode, + 'trigger_conversation_resolved' => Captain::Workflows::Nodes::TriggerNode, + 'condition' => Captain::Workflows::Nodes::ConditionNode, + 'send_message' => Captain::Workflows::Nodes::SendMessageNode, + 'add_label' => Captain::Workflows::Nodes::AddLabelNode, + 'assign_agent' => Captain::Workflows::Nodes::AssignAgentNode, + 'assign_team' => Captain::Workflows::Nodes::AssignTeamNode, + 'resolve_conversation' => Captain::Workflows::Nodes::ResolveConversationNode, + 'add_private_note' => Captain::Workflows::Nodes::AddPrivateNoteNode, + 'update_priority' => Captain::Workflows::Nodes::UpdatePriorityNode, + 'shopify_search_customer' => Captain::Workflows::Nodes::Shopify::SearchCustomerNode, + 'shopify_get_customer_orders' => Captain::Workflows::Nodes::Shopify::GetCustomerOrdersNode + }.freeze + + def self.resolve(node_type) + REGISTRY[node_type] + end +end diff --git a/enterprise/app/services/captain/workflows/nodes/add_label_node.rb b/enterprise/app/services/captain/workflows/nodes/add_label_node.rb new file mode 100644 index 000000000..b0a22caf6 --- /dev/null +++ b/enterprise/app/services/captain/workflows/nodes/add_label_node.rb @@ -0,0 +1,8 @@ +class Captain::Workflows::Nodes::AddLabelNode < Captain::Workflows::Nodes::BaseNode + def execute + label = node_data['label'] + conversation.label_list.add(label) + conversation.save! + { label: label } + end +end diff --git a/enterprise/app/services/captain/workflows/nodes/add_private_note_node.rb b/enterprise/app/services/captain/workflows/nodes/add_private_note_node.rb new file mode 100644 index 000000000..d53681963 --- /dev/null +++ b/enterprise/app/services/captain/workflows/nodes/add_private_note_node.rb @@ -0,0 +1,12 @@ +class Captain::Workflows::Nodes::AddPrivateNoteNode < Captain::Workflows::Nodes::BaseNode + def execute + content = interpolate(node_data['message']) + note = conversation.messages.create!( + account: account, + message_type: :activity, + content: content, + private: true + ) + { note_id: note.id } + end +end diff --git a/enterprise/app/services/captain/workflows/nodes/assign_agent_node.rb b/enterprise/app/services/captain/workflows/nodes/assign_agent_node.rb new file mode 100644 index 000000000..a7c7103e7 --- /dev/null +++ b/enterprise/app/services/captain/workflows/nodes/assign_agent_node.rb @@ -0,0 +1,7 @@ +class Captain::Workflows::Nodes::AssignAgentNode < Captain::Workflows::Nodes::BaseNode + def execute + agent_id = node_data['agent_id'] + conversation.update!(assignee_id: agent_id) + { agent_id: agent_id } + end +end diff --git a/enterprise/app/services/captain/workflows/nodes/assign_team_node.rb b/enterprise/app/services/captain/workflows/nodes/assign_team_node.rb new file mode 100644 index 000000000..b16429a77 --- /dev/null +++ b/enterprise/app/services/captain/workflows/nodes/assign_team_node.rb @@ -0,0 +1,7 @@ +class Captain::Workflows::Nodes::AssignTeamNode < Captain::Workflows::Nodes::BaseNode + def execute + team_id = node_data['team_id'] + conversation.update!(team_id: team_id) + { team_id: team_id } + end +end diff --git a/enterprise/app/services/captain/workflows/nodes/base_node.rb b/enterprise/app/services/captain/workflows/nodes/base_node.rb new file mode 100644 index 000000000..86a1d7db5 --- /dev/null +++ b/enterprise/app/services/captain/workflows/nodes/base_node.rb @@ -0,0 +1,40 @@ +class Captain::Workflows::Nodes::BaseNode + attr_reader :node_config, :context + + def initialize(node_config, context) + @node_config = node_config + @context = context + end + + def execute + raise NotImplementedError, "#{self.class} must implement #execute" + end + + private + + def account + context[:account] + end + + def conversation + context[:conversation] + end + + def contact + context[:contact] + end + + def node_data + node_config['data'] || {} + end + + def interpolate(template) + return template unless template.is_a?(String) + + template.gsub(/\{\{(\w+(?:\.\w+)*)\}\}/) do |match| + keys = ::Regexp.last_match(1).split('.') + value = context.dig(*keys.map(&:to_sym)) + value.nil? ? match : value.to_s + end + end +end diff --git a/enterprise/app/services/captain/workflows/nodes/condition_node.rb b/enterprise/app/services/captain/workflows/nodes/condition_node.rb new file mode 100644 index 000000000..4d3e1d57b --- /dev/null +++ b/enterprise/app/services/captain/workflows/nodes/condition_node.rb @@ -0,0 +1,42 @@ +class Captain::Workflows::Nodes::ConditionNode < Captain::Workflows::Nodes::BaseNode + def execute + result = evaluate_condition + { next_handle: result ? 'true' : 'false' } + end + + private + + def evaluate_condition + attribute = node_data['attribute'] + operator = node_data['operator'] + value = node_data['value'] + + actual_value = resolve_attribute(attribute) + compare(actual_value, operator, value) + end + + def resolve_attribute(attribute) + case attribute + when /\Aconversation\./ + conversation&.send(attribute.sub('conversation.', '')) + when /\Acontact\./ + contact&.send(attribute.sub('contact.', '')) + else + context.dig(*attribute.split('.').map(&:to_sym)) + end + rescue NoMethodError + nil + end + + def compare(actual, operator, expected) + case operator + when 'equals' then actual.to_s == expected.to_s + when 'not_equals' then actual.to_s != expected.to_s + when 'contains' then actual.to_s.include?(expected.to_s) + when 'not_contains' then actual.to_s.exclude?(expected.to_s) + when 'is_present' then actual.present? + when 'is_blank' then actual.blank? + else false + end + end +end diff --git a/enterprise/app/services/captain/workflows/nodes/resolve_conversation_node.rb b/enterprise/app/services/captain/workflows/nodes/resolve_conversation_node.rb new file mode 100644 index 000000000..d8692ad3e --- /dev/null +++ b/enterprise/app/services/captain/workflows/nodes/resolve_conversation_node.rb @@ -0,0 +1,6 @@ +class Captain::Workflows::Nodes::ResolveConversationNode < Captain::Workflows::Nodes::BaseNode + def execute + conversation.resolve! + { resolved: true } + end +end diff --git a/enterprise/app/services/captain/workflows/nodes/send_message_node.rb b/enterprise/app/services/captain/workflows/nodes/send_message_node.rb new file mode 100644 index 000000000..f4217b273 --- /dev/null +++ b/enterprise/app/services/captain/workflows/nodes/send_message_node.rb @@ -0,0 +1,11 @@ +class Captain::Workflows::Nodes::SendMessageNode < Captain::Workflows::Nodes::BaseNode + def execute + content = interpolate(node_data['message']) + message = conversation.messages.create!( + account: account, + message_type: :outgoing, + content: content + ) + { message_id: message.id } + end +end diff --git a/enterprise/app/services/captain/workflows/nodes/shopify/base_shopify_node.rb b/enterprise/app/services/captain/workflows/nodes/shopify/base_shopify_node.rb new file mode 100644 index 000000000..a6adbaafa --- /dev/null +++ b/enterprise/app/services/captain/workflows/nodes/shopify/base_shopify_node.rb @@ -0,0 +1,31 @@ +class Captain::Workflows::Nodes::Shopify::BaseShopifyNode < Captain::Workflows::Nodes::BaseNode + include ::Shopify::IntegrationHelper + + private + + def hook + @hook ||= Integrations::Hook.find_by!(account: account, app_id: 'shopify') + end + + def setup_shopify_context + ShopifyAPI::Context.setup( + api_key: client_id, + api_secret_key: client_secret, + api_version: '2025-01'.freeze, + scope: REQUIRED_SCOPES.join(','), + is_embedded: true, + is_private: false + ) + end + + def shopify_session + ShopifyAPI::Auth::Session.new(shop: hook.reference_id, access_token: hook.access_token) + end + + def shopify_client + @shopify_client ||= begin + setup_shopify_context + ShopifyAPI::Clients::Rest::Admin.new(session: shopify_session) + end + end +end diff --git a/enterprise/app/services/captain/workflows/nodes/shopify/get_customer_orders_node.rb b/enterprise/app/services/captain/workflows/nodes/shopify/get_customer_orders_node.rb new file mode 100644 index 000000000..797b3637f --- /dev/null +++ b/enterprise/app/services/captain/workflows/nodes/shopify/get_customer_orders_node.rb @@ -0,0 +1,17 @@ +class Captain::Workflows::Nodes::Shopify::GetCustomerOrdersNode < Captain::Workflows::Nodes::Shopify::BaseShopifyNode + def execute + customer_id = context[:shopify_customer_id] || node_data['shopify_customer_id'] + return { orders: [], error: 'No Shopify customer ID available' } if customer_id.blank? + + orders = shopify_client.get( + path: 'orders.json', + query: { + customer_id: customer_id, + status: 'any', + fields: 'id,email,created_at,total_price,currency,fulfillment_status,financial_status' + } + ).body['orders'] || [] + + { orders: orders } + end +end diff --git a/enterprise/app/services/captain/workflows/nodes/shopify/search_customer_node.rb b/enterprise/app/services/captain/workflows/nodes/shopify/search_customer_node.rb new file mode 100644 index 000000000..4e5d83d46 --- /dev/null +++ b/enterprise/app/services/captain/workflows/nodes/shopify/search_customer_node.rb @@ -0,0 +1,23 @@ +class Captain::Workflows::Nodes::Shopify::SearchCustomerNode < Captain::Workflows::Nodes::Shopify::BaseShopifyNode + def execute + query = build_search_query + return { customers: [], error: 'No contact email or phone available' } if query.blank? + + customers = shopify_client.get( + path: 'customers/search.json', + query: { query: query, fields: 'id,email,phone,first_name,last_name' } + ).body['customers'] || [] + + context[:shopify_customer_id] = customers.first&.dig('id') + { customers: customers } + end + + private + + def build_search_query + parts = [] + parts << "email:#{contact.email}" if contact&.email.present? + parts << "phone:#{contact&.phone_number}" if contact&.phone_number.present? + parts.join(' OR ').presence + end +end diff --git a/enterprise/app/services/captain/workflows/nodes/trigger_node.rb b/enterprise/app/services/captain/workflows/nodes/trigger_node.rb new file mode 100644 index 000000000..71ec9b41d --- /dev/null +++ b/enterprise/app/services/captain/workflows/nodes/trigger_node.rb @@ -0,0 +1,5 @@ +class Captain::Workflows::Nodes::TriggerNode < Captain::Workflows::Nodes::BaseNode + def execute + { event: node_data['event'], triggered_at: Time.current.iso8601 } + end +end diff --git a/enterprise/app/services/captain/workflows/nodes/update_priority_node.rb b/enterprise/app/services/captain/workflows/nodes/update_priority_node.rb new file mode 100644 index 000000000..7426a2e66 --- /dev/null +++ b/enterprise/app/services/captain/workflows/nodes/update_priority_node.rb @@ -0,0 +1,7 @@ +class Captain::Workflows::Nodes::UpdatePriorityNode < Captain::Workflows::Nodes::BaseNode + def execute + priority = node_data['priority'] + conversation.update!(priority: priority) + { priority: priority } + end +end diff --git a/enterprise/app/views/api/v1/accounts/captain/workflows/create.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/workflows/create.json.jbuilder new file mode 100644 index 000000000..e2be4ab8d --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/workflows/create.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/captain/workflow', workflow: @workflow diff --git a/enterprise/app/views/api/v1/accounts/captain/workflows/executions.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/workflows/executions.json.jbuilder new file mode 100644 index 000000000..39cfaa145 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/workflows/executions.json.jbuilder @@ -0,0 +1,14 @@ +json.payload do + json.array! @executions do |execution| + json.id execution.id + json.workflow_id execution.workflow_id + json.conversation_id execution.conversation_id + json.contact_id execution.contact_id + json.status execution.status + json.started_at execution.started_at + json.completed_at execution.completed_at + json.error_message execution.error_message + json.execution_log execution.execution_log + json.created_at execution.created_at + end +end diff --git a/enterprise/app/views/api/v1/accounts/captain/workflows/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/workflows/index.json.jbuilder new file mode 100644 index 000000000..f7039b047 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/workflows/index.json.jbuilder @@ -0,0 +1,10 @@ +json.payload do + json.array! @workflows do |workflow| + json.partial! 'api/v1/models/captain/workflow', workflow: workflow + end +end + +json.meta do + json.total_count @workflows.count + json.page 1 +end diff --git a/enterprise/app/views/api/v1/accounts/captain/workflows/show.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/workflows/show.json.jbuilder new file mode 100644 index 000000000..e2be4ab8d --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/workflows/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/captain/workflow', workflow: @workflow diff --git a/enterprise/app/views/api/v1/accounts/captain/workflows/update.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/workflows/update.json.jbuilder new file mode 100644 index 000000000..e2be4ab8d --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/workflows/update.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/captain/workflow', workflow: @workflow diff --git a/enterprise/app/views/api/v1/models/captain/_workflow.json.jbuilder b/enterprise/app/views/api/v1/models/captain/_workflow.json.jbuilder new file mode 100644 index 000000000..375125d8e --- /dev/null +++ b/enterprise/app/views/api/v1/models/captain/_workflow.json.jbuilder @@ -0,0 +1,12 @@ +json.id workflow.id +json.name workflow.name +json.description workflow.description +json.trigger_event workflow.trigger_event +json.trigger_conditions workflow.trigger_conditions +json.nodes workflow.nodes +json.edges workflow.edges +json.enabled workflow.enabled +json.assistant_id workflow.assistant_id +json.account_id workflow.account_id +json.created_at workflow.created_at +json.updated_at workflow.updated_at diff --git a/package.json b/package.json index b1aa60f00..4d1d627ea 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@tanstack/vue-table": "^8.20.5", "@twilio/voice-sdk": "^2.12.4", "@vitejs/plugin-vue": "^5.1.4", + "@vue-flow/core": "^1.48.2", "@vue/compiler-sfc": "^3.5.8", "@vuelidate/core": "^2.0.3", "@vuelidate/validators": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 539a99b21..34172d5bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,6 +79,9 @@ importers: '@vitejs/plugin-vue': specifier: ^5.1.4 version: 5.1.4(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2)) + '@vue-flow/core': + specifier: ^1.48.2 + version: 1.48.2(vue@3.5.12(typescript@5.6.2)) '@vue/compiler-sfc': specifier: ^3.5.8 version: 3.5.8 @@ -1418,6 +1421,11 @@ packages: '@vitest/utils@3.0.5': resolution: {integrity: sha512-N9AX0NUoUtVwKwy21JtwzaqR5L5R5A99GAbrHfCCXK1lp593i/3AZAXhSP43wRQuxYsflrdzEfXZFo1reR1Nkg==} + '@vue-flow/core@1.48.2': + resolution: {integrity: sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==} + peerDependencies: + vue: ^3.3.0 + '@vue/compiler-core@3.5.12': resolution: {integrity: sha512-ISyBTRMmMYagUxhcpyEH0hpXRd/KqDU4ymofPgl2XAkY9ZhQ+h0ovEZJIiPop13UmR/54oA2cgMDjgroRelaEw==} @@ -1530,12 +1538,21 @@ packages: '@vueuse/components@12.0.0': resolution: {integrity: sha512-XpOoBXYRuFuUiiq+HsMX6rGzqvcHdKnbT4sbR0FHYxwSGBHO3Zli8pPTZoLRNBGp4CGov7BRCnANEK/1Ch/6tQ==} + '@vueuse/core@10.11.1': + resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==} + '@vueuse/core@12.0.0': resolution: {integrity: sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==} + '@vueuse/metadata@10.11.1': + resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==} + '@vueuse/metadata@12.0.0': resolution: {integrity: sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==} + '@vueuse/shared@10.11.1': + resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==} + '@vueuse/shared@12.0.0': resolution: {integrity: sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==} @@ -2009,6 +2026,44 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + data-urls@3.0.2: resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} engines: {node: '>=12'} @@ -4622,6 +4677,17 @@ packages: '@vue/composition-api': optional: true + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + vue-dompurify-html@5.1.0: resolution: {integrity: sha512-616o2/PBdOLM2bwlRWLdzeEC9NerLkwiudqNgaIJ5vBQWXec+u7Kuzh+45DtQQrids67s4pHnTnJZLVfyPMxbA==} peerDependencies: @@ -5999,6 +6065,17 @@ snapshots: loupe: 3.1.3 tinyrainbow: 2.0.0 + '@vue-flow/core@1.48.2(vue@3.5.12(typescript@5.6.2))': + dependencies: + '@vueuse/core': 10.11.1(vue@3.5.12(typescript@5.6.2)) + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + vue: 3.5.12(typescript@5.6.2) + transitivePeerDependencies: + - '@vue/composition-api' + '@vue/compiler-core@3.5.12': dependencies: '@babel/parser': 7.26.2 @@ -6184,6 +6261,16 @@ snapshots: transitivePeerDependencies: - typescript + '@vueuse/core@10.11.1(vue@3.5.12(typescript@5.6.2))': + dependencies: + '@types/web-bluetooth': 0.0.20 + '@vueuse/metadata': 10.11.1 + '@vueuse/shared': 10.11.1(vue@3.5.12(typescript@5.6.2)) + vue-demi: 0.14.10(vue@3.5.12(typescript@5.6.2)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + '@vueuse/core@12.0.0(typescript@5.6.2)': dependencies: '@types/web-bluetooth': 0.0.20 @@ -6193,8 +6280,17 @@ snapshots: transitivePeerDependencies: - typescript + '@vueuse/metadata@10.11.1': {} + '@vueuse/metadata@12.0.0': {} + '@vueuse/shared@10.11.1(vue@3.5.12(typescript@5.6.2))': + dependencies: + vue-demi: 0.14.10(vue@3.5.12(typescript@5.6.2)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + '@vueuse/shared@12.0.0(typescript@5.6.2)': dependencies: vue: 3.5.13(typescript@5.6.2) @@ -6720,6 +6816,42 @@ snapshots: csstype@3.1.3: {} + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + data-urls@3.0.2: dependencies: abab: 2.0.6 @@ -9672,6 +9804,10 @@ snapshots: dependencies: vue: 3.5.12(typescript@5.6.2) + vue-demi@0.14.10(vue@3.5.12(typescript@5.6.2)): + dependencies: + vue: 3.5.12(typescript@5.6.2) + vue-dompurify-html@5.1.0(vue@3.5.12(typescript@5.6.2)): dependencies: dompurify: 3.2.4