diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index e3942a9c..7e071bff 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -16,9 +16,9 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc ## Current Baseline -- Latest implementation checkpoint: `b36cf07 feat(csat): align report download csv`. +- Latest implementation checkpoint: `feat(automation): align automation rule payloads`. - Latest documentation checkpoint: this checkpoint, `docs: land automation macro execution plan`. -- Worktree status at this planning checkpoint: clean after `20941b9`; next active slice is B9.1a automation-rule CRUD payload and validation parity. +- Worktree status at this implementation checkpoint: B9.1a automation-rule CRUD payload and validation parity implemented; next active slice is B9.1b trigger coverage and action side-effect review. - `go test ./...` passes. - Route dump succeeds with `TOTAL: 830` after adding the Chatwoot-compatible applied-SLA index route. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -136,12 +136,13 @@ This ledger records the committed parity checkpoints that future slices should b | `b36cf07 feat(csat): align report download csv` | Completed B8.4 CSAT CSV download parity for the reused reports screen: `/csat_survey_responses/download` now writes real CSV via `encoding/csv`, uses Chatwoot's report headers, includes enterprise review notes, emits absolute conversation links using conversation display IDs, appends the reporting-period row, and applies the same account/date/agent/inbox/team/rating filters as list/metrics. | `go test ./internal/handler/api/v1 -run CsatSurvey -count=1`; `go test ./internal/handler/api/v1 -count=1`; `go test ./internal/automation -count=1`; `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 830`. | B8 moves to Review. Continue B9 automation and macro side-effect parity. | | `20941b9 docs: record csat download checkpoint` | Recorded the B8.4 CSAT download checkpoint and moved the active queue to B9 automation/macro side-effect parity. | Documentation-only checkpoint; no route changes. | Continue B9.1a automation-rule CRUD request/response and validation parity. | | `docs: land automation macro execution plan` | Landed the B9 execution contract into this tracker before code changes: Chatwoot automation-rule controller/frontend contracts, current Go handler/service gaps, B9.1a implementation checklist, focused verification commands, and commit/documentation rules. | Documentation-only checkpoint; `git diff --check` passed before commit. | Start B9.1a implementation. | +| `feat(automation): align automation rule payloads` | Completed B9.1a automation-rule CRUD contract parity for the reused Chatwoot frontend: list/show/update/clone now return `{ payload }`, create returns the raw rule object, delete returns empty `200 OK`, conditions accept `attribute_key` and Chatwoot operators such as `equal_to`, action params accept frontend arrays, responses serialize `attribute_key`, array `action_params`, Unix `created_on`, and update/delete/clone are account-scoped. | `go test ./internal/handler/api/v1 -run AutomationRule -count=1`; `go test ./internal/automation -run 'AutomationRule\|Condition\|Action' -count=1`; `go test ./internal/handler/api/v1 -count=1`; `go test ./internal/automation -count=1`; `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 830`. | Continue B9.1b trigger coverage, B9.1c execution logs/stop-on-match, and B9.2 macro payload/execute parity. | ## Next Slice Contract Completed implementation slice: B5.1-B5.5 now cover inbox serializer shape, Chatwoot frontend create/update binding, working-hours persistence, out-of-office behavior, inbox member assignment payload/mutation semantics, channel-specific config depth, AgentCapacityPolicy/InboxCapacityLimit API data contracts, and dedicated Email/Twilio/LINE channel route response shapes. B6 is now in Review after `a16c23c` and `f08c743`: Chatwoot search response envelopes, frontend query params, Meilisearch sender filters, mocked hit serialization, env-gated live Meilisearch validation, release-mode DB fallback rejection, and reindex Meilisearch-only guard are covered. B7 is now in Review after `a98dc2c`, `95224fa`, `a11bb96`, `213bf2b`, `4f85ef1`, and `d23f3f0`, enforcing per-inbox capacity limits, aligning SLA policy CRUD payloads, persisting applied SLA records, making core FRT/NRT/RT breach events idempotent, aligning the applied-SLA reports API, and creating SLA miss notifications. B3 and B4 remain in review for deeper side effects and browser validation. -Next implementation slice: start B9.1a automation-rule CRUD request/response and validation parity; do not expand action side effects until CRUD contracts are frontend-compatible. +Next implementation slice: start B9.1b automation trigger coverage and action side-effect review now that B9.1a CRUD contracts are frontend-compatible. | Step | Required result | Reference source | Verification | | --- | --- | --- | --- | @@ -155,7 +156,8 @@ Next implementation slice: start B9.1a automation-rule CRUD request/response and | N8 | Keep B7.6 SLA notification and timing review as current SLA closeout baseline. | `Sla::EvaluateAppliedSlaService`, `SlaEvent#create_notifications`, SLA processing jobs. | Done by `d23f3f0`; SLA miss notifications fan out to participants/admins/assignee, repeated evaluation is idempotent, and current reference timing for `only_during_business_hours` is covered. | | N9 | Keep B8 CSAT account/public/send/download parity as the current CSAT baseline. | `CsatSurveyResponsesController`, public CSAT controller/views, `CsatSurveyService`, `CsatSurveyListener`, dashboard reports and survey frontend clients. | Done by `f441680`, `ef3a909`, and `b36cf07`; list/metrics/review-note payloads, public object-shaped submission, lock window, response idempotency, resolve-triggered message send, and CSV download are covered. | | N10 | Land B9.1a automation-rule controller/frontend contracts before implementation. | Chatwoot automation controller, Jbuilder views, dashboard automation API/store/helper files, current Go handler/service/validator. | Done by `docs: land automation macro execution plan`; reference contracts, Go gaps, implementation checklist, and exit commands are tracked below. | -| N11 | Update this tracker after every implementation checkpoint. | This document. | `git diff --check`; `go test ./...` for Go changes. | +| N11 | Keep B9.1a automation-rule CRUD payload parity as current automation API baseline. | Chatwoot automation controller/Jbuilder views, dashboard automation API/store/helper files, current Go handler/service/validator. | Done by `feat(automation): align automation rule payloads`; frontend envelopes, `attribute_key`, operator aliases, array action params, account-scoped update/delete/clone, and empty delete are covered. | +| N12 | Update this tracker after every implementation checkpoint. | This document. | `git diff --check`; `go test ./...` for Go changes. | Current B2 profile checkpoint: @@ -376,7 +378,7 @@ Upcoming enterprise task boards: | B8 | B8.2 | Align public CSAT submit/update flow, lock window, message linkage, and one-response-per-message behavior. | `reference/chatwoot/app/controllers/public/api/v1/csat_survey_controller.rb`, CSAT response model/services. | Public handler tests for submit, update within window, update after lock, duplicate response rejection. | Done by `ef3a909` | | B8 | B8.3 | Wire resolve-triggered CSAT survey send with idempotency and channel-aware message creation. | Chatwoot CSAT listener/jobs and inbox CSAT settings. | Listener tests prove one CSAT message per resolved conversation and no send when disabled. | Done by `ef3a909`; WhatsApp/Twilio external template dispatch remains integration follow-up | | B8 | B8.4 | Align CSAT downloads/export payloads used by reports screens. | CSAT report/download controller paths and frontend report API. | CSV/download tests cover filters and frontend column names. | Done by `b36cf07` | -| B9 | B9.1 | Align automation rule CRUD payloads, validation, condition groups, and event names. | Chatwoot automation rule controllers/models and dashboard automation builder. | Handler/service tests for create/update/list/show/delete and validation errors. | Todo | +| B9 | B9.1 | Align automation rule CRUD payloads, validation, condition groups, and event names. | Chatwoot automation rule controllers/models and dashboard automation builder. | Handler/service tests for create/update/list/show/delete and validation errors. | Doing; B9.1a done by `feat(automation): align automation rule payloads` | | B9 | B9.2 | Implement automation action side effects for labels, status, priority, assignee/team, custom attributes, private notes, and snooze. | Chatwoot automation action services/listeners. | Rule listener tests assert real conversation mutations and stop-on-match behavior. | Todo | | B9 | B9.3 | Add durable execution logs, delayed actions, webhook delivery, and email transcript delivery boundaries. | Chatwoot automation jobs and webhook/transcript actions. | Worker/action tests cover retryable external actions and observable failures. | Todo | | B9 | B9.4 | Align macro CRUD/availability and macro execution side effects. | Chatwoot macros controller/model/action execution. | Macro handler/service tests cover frontend payloads and conversation mutations. | Todo | @@ -404,7 +406,7 @@ B9 automation and macro execution breakdown: | Step | Implementation target | Reference source | Required tests | Status | | --- | --- | --- | --- | --- | -| B9.1a | Align automation-rule CRUD request/response envelopes, condition/action validation, and rule status toggles. | Chatwoot automation rules controller/model and dashboard automation client. | Handler tests for index/show/create/update/delete/toggle payloads and validation failures. | Todo | +| B9.1a | Align automation-rule CRUD request/response envelopes, condition/action validation, and rule status toggles. | Chatwoot automation rules controller/model and dashboard automation client. | Handler tests for index/show/create/update/delete/toggle payloads and validation failures. | Done by `feat(automation): align automation rule payloads` | | B9.1b | Implement real rule trigger coverage for conversation created/updated/resolved, message created, contact updated, and webhook/provider events. | Chatwoot automation listener and existing `channel.Dispatcher` events. | Listener tests prove matching rules execute once per event with account/inbox filters. | Todo | | B9.1c | Persist execution logs and stop-on-match behavior, including failed action records. | Chatwoot automation execution/log models and services. | Service tests cover success, skipped, failed, retryable, and stop-on-match flows. | Todo | | B9.1d | Make webhook and email transcript actions timeout-bound, retryable, and observable. | Chatwoot action services/jobs and Phase 5 worker plan. | Tests use fake HTTP/mailer boundaries and assert retry/log metadata. | Todo | @@ -442,6 +444,26 @@ env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./... git diff --check ``` +B9.1a current checkpoint: + +- Automation rule `index`, `show`, `update`, and `clone` now return the Chatwoot `{ payload }` envelopes; `create` returns the raw automation rule partial; `destroy` returns empty `200 OK`. +- The handler accepts Chatwoot frontend conditions with `attribute_key`, `custom_attribute_type`, and operators such as `equal_to`/`not_equal_to`, while normalizing to internal operators for matching. +- The handler accepts frontend action `action_params` arrays and normalizes common Chatwoot actions into the existing map-based action execution format without breaking existing automation service tests. +- The serializer returns `attribute_key`, Chatwoot operator names, array-shaped `action_params`, Unix `created_on`, and no local `{ success, data }` envelope. +- Get/update/delete/clone now scope rules by account before mutating; update preserves account ownership instead of saving zero-value `account_id` from partial client payloads. +- B9.1 remains `Doing` because trigger coverage, durable execution logs, stop-on-match, webhook retry, and email transcript boundaries are still active B9 follow-ups. + +B9.1a verification: + +```bash +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -run AutomationRule -count=1 +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/automation -run 'AutomationRule|Condition|Action' -count=1 +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -count=1 +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/automation -count=1 +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./... +git diff --check +``` + B10 admin and limits breakdown: | Step | Implementation target | Reference source | Required tests | Status | @@ -849,7 +871,7 @@ Enterprise tracking table: | P4.5 | Inbox limits | `internal/model/agent_capacity_policy.go` `InboxCapacityLimit`, plus legacy `internal/model/inbox_limit.go`, `internal/service/inbox_limit_service.go`, `internal/repository/inbox_limit_repo.go`, `internal/handler/api/v1/inbox_limit_handler.go` | Chatwoot enterprise nested `agent_capacity_policies/:policy_id/inbox_limits` and assignment-path enforcement are covered; next review account-level create-limit behavior and legacy route compatibility. | Review | | P4.6 | Captain/Copilot | `internal/model/captain_models.go`, `internal/model/copilot_models.go`, `internal/service/captain_*`, `internal/service/copilot_*`, `internal/handler/api/v1/captain_*`, `internal/handler/api/v1/copilot_handler.go` | Complete assistant, tools, scenarios, documents, responses, inbox bindings, suggestions, and streaming compatibility. | Todo | | P4.7 | CSAT | `internal/csat/*`, `internal/automation/csat_survey_*`, `internal/handler/api/v1/csat_*`, `internal/service/csat_metrics_service.go` | Account-side list/metrics/review-note payloads, public update depth, resolve-triggered generic survey send, and download CSV are aligned; channel-specific template send hooks remain Phase 5 integration work. | Review | -| P4.8 | Automation and macros | `internal/automation/*`, `internal/handler/api/v1/automation_rule_handler.go`, `internal/handler/api/v1/macro_handler.go` | Finish action side effects, execution logs, webhook/email transcript delivery, and rule trigger coverage. | Todo | +| P4.8 | Automation and macros | `internal/automation/*`, `internal/handler/api/v1/automation_rule_handler.go`, `internal/handler/api/v1/macro_handler.go` | Automation-rule CRUD request/response parity is implemented; next finish trigger coverage, action side effects, execution logs, webhook/email transcript delivery, and macro parity. | Doing | | P4.9 | Assignment policies | `internal/autoassignment/*`, `internal/automation/agent_bot_rule_listener.go` | Match Chatwoot assignment policy behavior and availability/capacity rules. | Doing | Enterprise work package breakdown: @@ -859,7 +881,7 @@ Enterprise work package breakdown: | SLA | Policy CRUD parity, conversation SLA assignment, first-response/next-response/resolution timers, business-hours handling, breach events, notifications. | Policy payloads, applied-SLA attach, seconds-based thresholds, core state transitions, breach idempotency, report payloads, reference `only_during_business_hours` timing, and notification fan-out are covered. Durable scheduling remains Phase 5. | Review | | Assignment and capacity | Assignment policy CRUD, inbox policy binding, round-robin/availability/capacity selection, manual assignment limits, fallback behavior. | Manual and automatic assignment respect policy, availability, team/inbox membership, and limits. | Review | | CSAT account/public side | Survey send on resolve, response list, metrics, filters, downloads, review notes, resend/idempotency, public lock. | Account report list/metrics/review-note payload fixtures are covered by `f441680`; public object-shaped submit/update, one-response-per-message idempotency, and resolve-triggered generic survey message send are covered by `ef3a909`; download CSV is covered by `b36cf07`. | Review | -| Automation rules | Condition/action parity, event listener coverage, delayed actions, execution logs, stop-on-match behavior, webhook and transcript actions. | Rule trigger tests for conversation/contact/message events and durable retry for external actions. | Todo | +| Automation rules | CRUD payload parity, condition/action parity, event listener coverage, delayed actions, execution logs, stop-on-match behavior, webhook and transcript actions. | B9.1a CRUD and frontend payload contracts are covered; next gates are rule trigger tests for conversation/contact/message events and durable retry for external actions. | Doing | | Macros | Macro CRUD, availability by account/user, execute side effects, validation, audit/log output. | Execute changes conversation labels/status/assignee/team/notes/custom attributes exactly as frontend expects. | Todo | | Audit | Audit model parity, mutating action coverage, request metadata, filters/pagination, admin endpoint payloads. | Representative mutations across core and enterprise features emit audit records. | Todo | | Custom roles | Permission-key parity, account-user role resolution, policy middleware, create/update/delete behavior. | Permission matrix tests and frontend admin payload fixtures. | Todo | @@ -1085,3 +1107,4 @@ Verification milestone gates: - 2026-06-05: B8 public/send CSAT checkpoint committed as `ef3a909 feat(csat): send surveys on resolved conversations`; public CSAT update now accepts object-shaped `message.submitted_values`, repeated submissions update the same message-linked response, and the 14-day lock still follows the survey message creation date. The resolve listener now creates one `input_csat` template message only when the resolved conversation's inbox has CSAT enabled and survey rules allow it, skips duplicates, and no longer creates pending rating-0 responses. Message-updated events build responses from submitted CSAT values. Focused CSAT listener/service/handler/widget tests, automation and API handler package tests, full `go test ./...`, and `git diff --check` passed. Next B8 checkpoint is CSAT download CSV parity. - 2026-06-05: B8 CSAT download checkpoint committed as `b36cf07 feat(csat): align report download csv`; CSAT report downloads now match Chatwoot CSV headers, filters, absolute conversation links by display ID, enterprise review notes, and the final reporting-period row. Focused CSAT handler tests, handler and automation package tests, full `go test ./...`, and `git diff --check` passed. B8 moves to Review; next slice is B9 automation and macro side-effect parity. - 2026-06-05: B9 planning checkpoint committed as `docs: land automation macro execution plan`; B9.1a now has an explicit Chatwoot automation-rule controller/frontend contract, current Go gap table, implementation checklist, and focused exit commands before automation/macro code changes begin. +- 2026-06-05: B9.1a automation-rule CRUD checkpoint committed as `feat(automation): align automation rule payloads`; automation-rule list/show/update/clone now return Chatwoot `{ payload }`, create returns the raw rule object, delete returns empty `200 OK`, frontend `attribute_key`/`equal_to` conditions and array `action_params` are accepted, responses serialize Chatwoot field names and Unix `created_on`, and get/update/delete/clone are account-scoped. Focused automation handler/service tests, handler and automation package tests, full `go test ./...`, and `git diff --check` passed. Next slice is B9.1b trigger coverage and action side-effect parity. diff --git a/internal/automation/condition_filter.go b/internal/automation/condition_filter.go index 11157ca7..816035c4 100644 --- a/internal/automation/condition_filter.go +++ b/internal/automation/condition_filter.go @@ -20,6 +20,7 @@ import ( // For attribute_changed conditions, the eventData map must contain "changed_attributes" // with from/to values for the attribute. func MatchConditions(ctx context.Context, db *gorm.DB, conditions Conditions, conv *ConversationForFilter, eventData map[string]interface{}) (bool, error) { + conditions = NormalizeConditions(conditions) if len(conditions) == 0 { // No conditions means the rule always matches return true, nil @@ -127,8 +128,11 @@ func matchDirectConditions(db *gorm.DB, conditions Conditions, conv *Conversatio // Reference: Chatwoot filter_operator mapping — equal, not_equal, contains, etc. func buildConditionClause(c Condition, conv *ConversationForFilter) (string, error) { attr := c.Attribute + if attr == "" { + attr = c.AttributeKey + } values := c.Values - op := c.FilterOperator + op := NormalizeFilterOperator(c.FilterOperator) // is_present / is_not_present don't require values; all other operators do. if len(values) == 0 && op != "is_present" && op != "is_not_present" { @@ -137,17 +141,17 @@ func buildConditionClause(c Condition, conv *ConversationForFilter) (string, err // Map condition attributes to database columns columnMap := map[string]string{ - "status": "status", - "priority": "priority", - "message_type": "", // needs message join, handled separately - "assignee_id": "assignee_id", - "team_id": "", // needs team join, handled separately - "inbox_id": "inbox_id", - "contact_id": "contact_id", - "channel_type": "channel_type", - "content": "", // needs message content check, handled separately - "email": "", // needs contact join, handled separately - "labels": "", // needs label join, handled separately + "status": "status", + "priority": "priority", + "message_type": "", // needs message join, handled separately + "assignee_id": "assignee_id", + "team_id": "", // needs team join, handled separately + "inbox_id": "inbox_id", + "contact_id": "contact_id", + "channel_type": "channel_type", + "content": "", // needs message content check, handled separately + "email": "", // needs contact join, handled separately + "labels": "", // needs label join, handled separately } column, mapped := columnMap[attr] @@ -212,6 +216,9 @@ func matchChangedConditions(conditions Conditions, eventData map[string]interfac for _, c := range conditions { attr := c.Attribute + if attr == "" { + attr = c.AttributeKey + } transition, ok := changedMap[attr] if !ok { // This attribute was not changed in this event @@ -242,4 +249,4 @@ func matchChangedConditions(conditions Conditions, eventData map[string]interfac } return true, nil -} \ No newline at end of file +} diff --git a/internal/automation/condition_validator.go b/internal/automation/condition_validator.go index f0841fd7..3060d234 100644 --- a/internal/automation/condition_validator.go +++ b/internal/automation/condition_validator.go @@ -2,39 +2,96 @@ package automation import "fmt" +var chatwootToLocalFilterOperator = map[string]string{ + "equal_to": "equal", + "not_equal_to": "not_equal", +} + +// NormalizeFilterOperator converts Chatwoot frontend operator names to the +// internal names used by the condition matcher. +func NormalizeFilterOperator(op string) string { + if normalized, ok := chatwootToLocalFilterOperator[op]; ok { + return normalized + } + return op +} + +// ChatwootFilterOperator converts internal operator names to the dashboard +// values used by Chatwoot's automation builder. +func ChatwootFilterOperator(op string) string { + switch op { + case "equal": + return "equal_to" + case "not_equal": + return "not_equal_to" + default: + return op + } +} + +// NormalizeConditions fills Chatwoot's attribute_key into the internal +// Attribute field and normalizes filter operator aliases before validation or +// execution. +func NormalizeConditions(conditions Conditions) Conditions { + for i := range conditions { + if conditions[i].Attribute == "" { + conditions[i].Attribute = conditions[i].AttributeKey + } + if conditions[i].AttributeKey == "" { + conditions[i].AttributeKey = conditions[i].Attribute + } + conditions[i].FilterOperator = NormalizeFilterOperator(conditions[i].FilterOperator) + } + return conditions +} + // WhitelistedConditionAttributes defines the set of valid condition attributes. // Reference: Chatwoot ConditionValidationService — validates condition keys against whitelisted attributes. var WhitelistedConditionAttributes = map[string]bool{ // Conversation attributes - "status": true, - "priority": true, - "assignee_id": true, - "team_id": true, - "inbox_id": true, - "contact_id": true, - "channel_type": true, - "labels": true, + "status": true, + "priority": true, + "assignee_id": true, + "team_id": true, + "inbox_id": true, + "contact_id": true, + "channel_type": true, + "labels": true, + "contact": true, // Message/content attributes - "content": true, - "message_type": true, - "email": true, + "content": true, + "message_type": true, + "private_note": true, + "email": true, + "mail_subject": true, + "phone_number": true, + "company_name": true, + "referer": true, + "campaigns": true, + "conversation_language": true, // Country/locale attributes (Chatwoot extensions) - "country_code": true, + "country_code": true, "browser_language": true, } // WhitelistedFilterOperators defines the set of valid filter operators. // Reference: Chatwoot filter_operator enum var WhitelistedFilterOperators = map[string]bool{ - "equal": true, - "not_equal": true, - "contains": true, - "does_not_contain": true, - "is_present": true, - "is_not_present": true, - "attribute_changed": true, + "equal": true, + "equal_to": true, + "not_equal": true, + "not_equal_to": true, + "contains": true, + "does_not_contain": true, + "is_present": true, + "is_not_present": true, + "attribute_changed": true, + "is_greater_than": true, + "is_less_than": true, + "starts_with": true, + "days_before": true, } // WhitelistedQueryOperators defines the set of valid query operators for combining conditions. @@ -46,22 +103,34 @@ var WhitelistedQueryOperators = map[string]bool{ // WhitelistedActionTypes defines the set of valid automation action types. // Reference: Chatwoot AutomationRule action_name enum var WhitelistedActionTypes = map[string]bool{ - "assign_agent": true, - "assign_team": true, // Chatwoot: assign conversation to a team - "change_status": true, - "add_label": true, - "remove_label": true, - "send_message": true, - "send_email_to_contact": true, - "mute_conversation": true, - "snooze_conversation": true, // Chatwoot: snooze until a specified time - "resolve_conversation": true, // Chatwoot: resolve immediately + "assign_agent": true, + "assign_team": true, // Chatwoot: assign conversation to a team + "change_status": true, + "add_label": true, + "remove_label": true, + "send_message": true, + "send_attachment": true, + "send_email_to_contact": true, + "send_email_to_team": true, + "send_email_transcript": true, + "send_webhook_event": true, + "mute_conversation": true, + "snooze_conversation": true, // Chatwoot: snooze until a specified time + "resolve_conversation": true, // Chatwoot: resolve immediately + "open_conversation": true, + "pending_conversation": true, + "change_priority": true, + "add_sla": true, + "add_private_note": true, + "remove_assigned_agent": true, + "remove_assigned_team": true, } // ValidateConditions checks that all conditions use valid attributes, filter operators, // and query operators. // Reference: Chatwoot ConditionValidationService — validates condition keys against whitelisted attributes func ValidateConditions(conditions Conditions) error { + conditions = NormalizeConditions(conditions) for i, c := range conditions { // Validate attribute if !WhitelistedConditionAttributes[c.Attribute] { @@ -69,7 +138,8 @@ func ValidateConditions(conditions Conditions) error { } // Validate filter operator - if !WhitelistedFilterOperators[c.FilterOperator] { + filterOperator := NormalizeFilterOperator(c.FilterOperator) + if !WhitelistedFilterOperators[filterOperator] { return fmt.Errorf("condition[%d]: invalid filter_operator '%s'", i, c.FilterOperator) } @@ -81,14 +151,14 @@ func ValidateConditions(conditions Conditions) error { } // Validate values — attribute_changed requires from/to values - if c.FilterOperator == "attribute_changed" { + if filterOperator == "attribute_changed" { if len(c.Values) == 0 { return fmt.Errorf("condition[%d]: attribute_changed requires at least one value (to)", i) } } // is_present / is_not_present should not need values - if c.FilterOperator == "is_present" || c.FilterOperator == "is_not_present" { + if filterOperator == "is_present" || filterOperator == "is_not_present" { // Values are optional for presence checks — not enforced } else if len(c.Values) == 0 { return fmt.Errorf("condition[%d]: filter_operator '%s' requires at least one value", i, c.FilterOperator) @@ -107,4 +177,4 @@ func ValidateActions(actions Actions) error { } } return nil -} \ No newline at end of file +} diff --git a/internal/automation/model.go b/internal/automation/model.go index ff9455c5..27363faf 100644 --- a/internal/automation/model.go +++ b/internal/automation/model.go @@ -14,12 +14,14 @@ import ( // =========================== // Condition represents a single filter condition in an automation rule. -// Reference: Chatwoot automation_rule conditions — {attribute, filter_operator, values, query_operator} +// Reference: Chatwoot automation_rule conditions — {attribute_key, filter_operator, values, query_operator} type Condition struct { - Attribute string `json:"attribute"` - FilterOperator string `json:"filter_operator"` - Values []string `json:"values"` - QueryOperator string `json:"query_operator"` // "and" or "or" + Attribute string `json:"attribute,omitempty"` + AttributeKey string `json:"attribute_key,omitempty" gorm:"-"` + FilterOperator string `json:"filter_operator"` + Values []string `json:"values"` + QueryOperator string `json:"query_operator,omitempty"` // "and" or "or" + CustomAttributeType string `json:"custom_attribute_type,omitempty"` } // Conditions is a slice of Condition that implements GORM's JSON-valued column type. diff --git a/internal/automation/service.go b/internal/automation/service.go index e148e86f..56d0aed5 100644 --- a/internal/automation/service.go +++ b/internal/automation/service.go @@ -34,6 +34,17 @@ func (s *AutomationRuleService) GetByID(ctx context.Context, id uint) (*Automati return &rule, nil } +// GetByIDForAccount retrieves an automation rule scoped to an account. +func (s *AutomationRuleService) GetByIDForAccount(ctx context.Context, accountID, id uint) (*AutomationRule, error) { + var rule AutomationRule + if err := s.db.DB().WithContext(ctx). + Where("account_id = ?", accountID). + First(&rule, id).Error; err != nil { + return nil, err + } + return &rule, nil +} + // ListByAccount retrieves all automation rules for an account. func (s *AutomationRuleService) ListByAccount(ctx context.Context, accountID uint) ([]AutomationRule, error) { var rules []AutomationRule @@ -62,6 +73,7 @@ func (s *AutomationRuleService) ListActiveByAccountAndEvent(ctx context.Context, // Create creates a new automation rule. // New rules default to active unless explicitly set to inactive. func (s *AutomationRuleService) Create(ctx context.Context, rule *AutomationRule) error { + rule.Conditions = NormalizeConditions(rule.Conditions) // Validate conditions before saving if err := ValidateConditions(rule.Conditions); err != nil { return fmt.Errorf("invalid conditions: %w", err) @@ -80,6 +92,7 @@ func (s *AutomationRuleService) Create(ctx context.Context, rule *AutomationRule // Update updates an existing automation rule. func (s *AutomationRuleService) Update(ctx context.Context, rule *AutomationRule) error { + rule.Conditions = NormalizeConditions(rule.Conditions) // Validate conditions before saving if err := ValidateConditions(rule.Conditions); err != nil { return fmt.Errorf("invalid conditions: %w", err) @@ -91,11 +104,39 @@ func (s *AutomationRuleService) Update(ctx context.Context, rule *AutomationRule return s.db.DB().WithContext(ctx).Save(rule).Error } +// UpdateForAccount updates an existing automation rule within account scope. +func (s *AutomationRuleService) UpdateForAccount(ctx context.Context, accountID uint, rule *AutomationRule) error { + var existing AutomationRule + if err := s.db.DB().WithContext(ctx). + Where("account_id = ?", accountID). + First(&existing, rule.ID).Error; err != nil { + return err + } + + existing.Name = rule.Name + existing.Description = rule.Description + existing.EventName = rule.EventName + existing.Conditions = NormalizeConditions(rule.Conditions) + existing.Actions = rule.Actions + existing.Active = rule.Active + + return s.Update(ctx, &existing) +} + // Delete soft-deletes an automation rule. func (s *AutomationRuleService) Delete(ctx context.Context, id uint) error { return s.db.DB().WithContext(ctx).Delete(&AutomationRule{}, id).Error } +// DeleteForAccount deletes an automation rule scoped to an account. +func (s *AutomationRuleService) DeleteForAccount(ctx context.Context, accountID, id uint) error { + rule, err := s.GetByIDForAccount(ctx, accountID, id) + if err != nil { + return err + } + return s.db.DB().WithContext(ctx).Delete(rule).Error +} + // Clone duplicates an automation rule, creating a new copy with "(copy)" suffix and Active=false. // Reference: Chatwoot AutomationRulesController#clone func (s *AutomationRuleService) Clone(ctx context.Context, id uint) (*AutomationRule, error) { @@ -121,6 +162,30 @@ func (s *AutomationRuleService) Clone(ctx context.Context, id uint) (*Automation return cloned, nil } +// CloneForAccount duplicates an automation rule scoped to an account. +func (s *AutomationRuleService) CloneForAccount(ctx context.Context, accountID, id uint) (*AutomationRule, error) { + original, err := s.GetByIDForAccount(ctx, accountID, id) + if err != nil { + return nil, err + } + + cloned := &AutomationRule{ + AccountID: original.AccountID, + EventName: original.EventName, + Name: original.Name + " (copy)", + Description: original.Description, + Conditions: original.Conditions, + Actions: original.Actions, + Active: false, + } + + if err := s.db.DB().WithContext(ctx).Create(cloned).Error; err != nil { + return nil, err + } + + return cloned, nil +} + // ToggleActive toggles the active state of an automation rule and sets the corresponding timestamp. func (s *AutomationRuleService) ToggleActive(ctx context.Context, id uint, active bool) error { now := time.Now() @@ -140,6 +205,14 @@ func (s *AutomationRuleService) ToggleActive(ctx context.Context, id uint, activ Updates(updates).Error } +// ToggleActiveForAccount toggles the active state within account scope. +func (s *AutomationRuleService) ToggleActiveForAccount(ctx context.Context, accountID, id uint, active bool) error { + if _, err := s.GetByIDForAccount(ctx, accountID, id); err != nil { + return err + } + return s.ToggleActive(ctx, id, active) +} + // MatchAndExecute checks all active rules for the given event and account, // runs condition filters for each rule, and executes actions for matching rules. // Reference: Chatwoot AutomationRuleListener — loads rules, runs ConditionsFilterService, then ActionService @@ -237,4 +310,4 @@ type ConversationForFilter struct { } // TableName overrides for GORM queries. -func (ConversationForFilter) TableName() string { return "conversations" } \ No newline at end of file +func (ConversationForFilter) TableName() string { return "conversations" } diff --git a/internal/handler/api/v1/automation_rule_handler.go b/internal/handler/api/v1/automation_rule_handler.go index 9de2ca45..57cdc29c 100644 --- a/internal/handler/api/v1/automation_rule_handler.go +++ b/internal/handler/api/v1/automation_rule_handler.go @@ -1,8 +1,8 @@ package v1 import ( + "encoding/json" "net/http" - "strconv" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/automation" @@ -20,6 +20,29 @@ func NewAutomationRuleHandler(svc *automation.AutomationRuleService) *Automation return &AutomationRuleHandler{svc: svc} } +type automationRuleRequest struct { + Name string `json:"name"` + Description string `json:"description"` + EventName string `json:"event_name"` + Active *bool `json:"active"` + Conditions []automationRuleConditionRequest `json:"conditions"` + Actions []automationRuleActionRequest `json:"actions"` +} + +type automationRuleConditionRequest struct { + AttributeKey string `json:"attribute_key"` + Attribute string `json:"attribute"` + FilterOperator string `json:"filter_operator"` + Values []interface{} `json:"values"` + QueryOperator string `json:"query_operator"` + CustomAttributeType string `json:"custom_attribute_type"` +} + +type automationRuleActionRequest struct { + ActionName string `json:"action_name"` + ActionParams json.RawMessage `json:"action_params"` +} + // List retrieves all automation rules for an account. // GET /api/v1/accounts/:account_id/automation_rules func (h *AutomationRuleHandler) List(c *gin.Context) { @@ -36,15 +59,15 @@ func (h *AutomationRuleHandler) List(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "automation_rules": rules, - "meta": gin.H{"count": len(rules)}, + "payload": serializeAutomationRules(rules), }) } // Get retrieves a single automation rule by ID. // GET /api/v1/accounts/:account_id/automation_rules/:id func (h *AutomationRuleHandler) Get(c *gin.Context) { - if _, err := parseUintParam(c, "account_id"); err != nil { + accountID, err := parseUintParam(c, "account_id") + if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } @@ -55,13 +78,13 @@ func (h *AutomationRuleHandler) Get(c *gin.Context) { return } - rule, svcErr := h.svc.GetByID(c.Request.Context(), automationID) + rule, svcErr := h.svc.GetByIDForAccount(c.Request.Context(), accountID, automationID) if svcErr != nil { handleServiceError(c, svcErr) return } - response.OK(c, rule) + c.JSON(http.StatusOK, gin.H{"payload": serializeAutomationRule(rule)}) } // Create creates a new automation rule. @@ -73,30 +96,35 @@ func (h *AutomationRuleHandler) Create(c *gin.Context) { return } - var rule automation.AutomationRule - if err := c.ShouldBindJSON(&rule); err != nil { + var req automationRuleRequest + if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } - if rule.Name == "" || rule.EventName == "" { + if req.Name == "" || req.EventName == "" { response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "name and event_name are required") return } - rule.AccountID = accountID + rule, bindErr := buildAutomationRuleFromRequest(accountID, req) + if bindErr != nil { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, bindErr.Error()) + return + } - if svcErr := h.svc.Create(c.Request.Context(), &rule); svcErr != nil { + if svcErr := h.svc.Create(c.Request.Context(), rule); svcErr != nil { handleServiceError(c, svcErr) return } - response.Created(c, rule) + c.JSON(http.StatusOK, serializeAutomationRule(rule)) } // Update updates an existing automation rule. // PUT /api/v1/accounts/:account_id/automation_rules/:id func (h *AutomationRuleHandler) Update(c *gin.Context) { - if _, err := parseUintParam(c, "account_id"); err != nil { + accountID, err := parseUintParam(c, "account_id") + if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } @@ -107,30 +135,41 @@ func (h *AutomationRuleHandler) Update(c *gin.Context) { return } - var rule automation.AutomationRule - if err := c.ShouldBindJSON(&rule); err != nil { + var req automationRuleRequest + if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } - if rule.Name == "" || rule.EventName == "" { + if req.Name == "" || req.EventName == "" { response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "name and event_name are required") return } + rule, bindErr := buildAutomationRuleFromRequest(accountID, req) + if bindErr != nil { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, bindErr.Error()) + return + } rule.ID = automationID - if svcErr := h.svc.Update(c.Request.Context(), &rule); svcErr != nil { + if svcErr := h.svc.UpdateForAccount(c.Request.Context(), accountID, rule); svcErr != nil { handleServiceError(c, svcErr) return } - response.OK(c, rule) + updated, svcErr := h.svc.GetByIDForAccount(c.Request.Context(), accountID, automationID) + if svcErr != nil { + handleServiceError(c, svcErr) + return + } + c.JSON(http.StatusOK, gin.H{"payload": serializeAutomationRule(updated)}) } // Delete soft-deletes an automation rule. // DELETE /api/v1/accounts/:account_id/automation_rules/:id func (h *AutomationRuleHandler) Delete(c *gin.Context) { - if _, err := parseUintParam(c, "account_id"); err != nil { + accountID, err := parseUintParam(c, "account_id") + if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } @@ -141,21 +180,19 @@ func (h *AutomationRuleHandler) Delete(c *gin.Context) { return } - if svcErr := h.svc.Delete(c.Request.Context(), automationID); svcErr != nil { + if svcErr := h.svc.DeleteForAccount(c.Request.Context(), accountID, automationID); svcErr != nil { handleServiceError(c, svcErr) return } - c.JSON(http.StatusOK, gin.H{ - "id": strconv.FormatUint(uint64(automationID), 10), - "deleted": true, - }) + c.Status(http.StatusOK) } // Clone duplicates an automation rule. // POST /api/v1/accounts/:account_id/automation_rules/:id/clone func (h *AutomationRuleHandler) Clone(c *gin.Context) { - if _, err := parseUintParam(c, "account_id"); err != nil { + accountID, err := parseUintParam(c, "account_id") + if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } @@ -166,20 +203,21 @@ func (h *AutomationRuleHandler) Clone(c *gin.Context) { return } - cloned, svcErr := h.svc.Clone(c.Request.Context(), automationID) + cloned, svcErr := h.svc.CloneForAccount(c.Request.Context(), accountID, automationID) if svcErr != nil { handleServiceError(c, svcErr) return } - response.Created(c, cloned) + c.JSON(http.StatusOK, gin.H{"payload": serializeAutomationRule(cloned)}) } // ToggleActive toggles the active state of an automation rule. // POST /api/v1/accounts/:account_id/automation_rules/:id/toggle_active // Reference: Chatwoot does not have a toggle_active endpoint; gochat adds this per M6 requirements. func (h *AutomationRuleHandler) ToggleActive(c *gin.Context) { - if _, err := parseUintParam(c, "account_id"); err != nil { + accountID, err := parseUintParam(c, "account_id") + if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } @@ -202,13 +240,264 @@ func (h *AutomationRuleHandler) ToggleActive(c *gin.Context) { return } - if svcErr := h.svc.ToggleActive(c.Request.Context(), automationID, *req.Active); svcErr != nil { + if svcErr := h.svc.ToggleActiveForAccount(c.Request.Context(), accountID, automationID, *req.Active); svcErr != nil { handleServiceError(c, svcErr) return } - c.JSON(http.StatusOK, gin.H{ - "id": strconv.FormatUint(uint64(automationID), 10), - "active": *req.Active, - }) + rule, svcErr := h.svc.GetByIDForAccount(c.Request.Context(), accountID, automationID) + if svcErr != nil { + handleServiceError(c, svcErr) + return + } + c.JSON(http.StatusOK, gin.H{"payload": serializeAutomationRule(rule)}) +} + +func buildAutomationRuleFromRequest(accountID uint, req automationRuleRequest) (*automation.AutomationRule, error) { + active := true + if req.Active != nil { + active = *req.Active + } + + conditions := make(automation.Conditions, 0, len(req.Conditions)) + for _, condition := range req.Conditions { + attribute := condition.Attribute + if attribute == "" { + attribute = condition.AttributeKey + } + values := make([]string, 0, len(condition.Values)) + for _, value := range condition.Values { + if value == nil { + continue + } + values = append(values, valueToString(value)) + } + conditions = append(conditions, automation.Condition{ + Attribute: attribute, + AttributeKey: attribute, + FilterOperator: automation.NormalizeFilterOperator(condition.FilterOperator), + Values: values, + QueryOperator: condition.QueryOperator, + CustomAttributeType: condition.CustomAttributeType, + }) + } + + actions := make(automation.Actions, 0, len(req.Actions)) + for _, actionReq := range req.Actions { + params, err := normalizeAutomationActionParams(actionReq.ActionName, actionReq.ActionParams) + if err != nil { + return nil, err + } + actions = append(actions, automation.Action{ActionName: actionReq.ActionName, ActionParams: params}) + } + + return &automation.AutomationRule{ + AccountID: accountID, + Name: req.Name, + Description: req.Description, + EventName: req.EventName, + Conditions: automation.NormalizeConditions(conditions), + Actions: actions, + Active: active, + }, nil +} + +func serializeAutomationRules(rules []automation.AutomationRule) []gin.H { + result := make([]gin.H, 0, len(rules)) + for i := range rules { + result = append(result, serializeAutomationRule(&rules[i])) + } + return result +} + +func serializeAutomationRule(rule *automation.AutomationRule) gin.H { + return gin.H{ + "id": rule.ID, + "account_id": rule.AccountID, + "name": rule.Name, + "description": rule.Description, + "event_name": rule.EventName, + "conditions": serializeAutomationConditions(rule.Conditions), + "actions": serializeAutomationActions(rule.Actions), + "created_on": rule.CreatedAt.Unix(), + "active": rule.Active, + } +} + +func serializeAutomationConditions(conditions automation.Conditions) []gin.H { + conditions = automation.NormalizeConditions(conditions) + result := make([]gin.H, 0, len(conditions)) + for _, condition := range conditions { + item := gin.H{ + "attribute_key": condition.Attribute, + "filter_operator": automation.ChatwootFilterOperator(condition.FilterOperator), + "values": condition.Values, + } + if condition.QueryOperator != "" { + item["query_operator"] = condition.QueryOperator + } + if condition.CustomAttributeType != "" { + item["custom_attribute_type"] = condition.CustomAttributeType + } + result = append(result, item) + } + return result +} + +func serializeAutomationActions(actions automation.Actions) []gin.H { + result := make([]gin.H, 0, len(actions)) + for _, action := range actions { + result = append(result, gin.H{ + "action_name": action.ActionName, + "action_params": chatwootActionParams(action), + }) + } + return result +} + +func normalizeAutomationActionParams(actionName string, raw json.RawMessage) (map[string]interface{}, error) { + if len(raw) == 0 || string(raw) == "null" { + return map[string]interface{}{}, nil + } + var object map[string]interface{} + if err := json.Unmarshal(raw, &object); err == nil { + if object == nil { + object = map[string]interface{}{} + } + return object, nil + } + var values []interface{} + if err := json.Unmarshal(raw, &values); err != nil { + var single interface{} + if singleErr := json.Unmarshal(raw, &single); singleErr != nil { + return nil, err + } + values = []interface{}{single} + } + return actionArrayToMap(actionName, values), nil +} + +func actionArrayToMap(actionName string, values []interface{}) map[string]interface{} { + params := map[string]interface{}{} + first := firstValue(values) + switch actionName { + case "assign_agent": + params["assignee_id"] = first + case "assign_team", "send_email_to_team": + params["team_id"] = first + case "add_label", "remove_label": + params["labels"] = valuesToStrings(values) + case "change_status": + params["status"] = first + case "change_priority": + params["priority"] = first + case "send_message", "add_private_note": + params["content"] = first + case "send_email_to_contact", "send_email_transcript": + params["email"] = first + case "send_attachment": + params["blob_id"] = first + case "add_sla": + params["sla_policy_id"] = first + default: + params["values"] = values + } + return params +} + +func chatwootActionParams(action automation.Action) []interface{} { + params := action.ActionParams + if params == nil { + return []interface{}{} + } + switch action.ActionName { + case "assign_agent": + return compactValues(params["assignee_id"], params["agent_id"]) + case "assign_team", "send_email_to_team": + return compactValues(params["team_id"]) + case "add_label", "remove_label": + if labels, ok := params["labels"]; ok { + return valuesToInterfaces(labels) + } + return compactValues(params["label"]) + case "change_status": + return compactValues(params["status"]) + case "change_priority": + return compactValues(params["priority"]) + case "send_message", "add_private_note": + return compactValues(params["content"], params["message"]) + case "send_email_to_contact", "send_email_transcript": + return compactValues(params["email"]) + case "send_attachment": + return compactValues(params["blob_id"], params["attachment_url"]) + case "add_sla": + return compactValues(params["sla_policy_id"]) + default: + if values, ok := params["values"]; ok { + return valuesToInterfaces(values) + } + return []interface{}{} + } +} + +func firstValue(values []interface{}) interface{} { + if len(values) == 0 { + return nil + } + return values[0] +} + +func compactValues(values ...interface{}) []interface{} { + for _, value := range values { + if value != nil { + return []interface{}{value} + } + } + return []interface{}{} +} + +func valuesToInterfaces(value interface{}) []interface{} { + switch typed := value.(type) { + case []interface{}: + return typed + case []string: + result := make([]interface{}, 0, len(typed)) + for _, item := range typed { + result = append(result, item) + } + return result + case nil: + return []interface{}{} + default: + return []interface{}{typed} + } +} + +func valuesToStrings(values []interface{}) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + if value != nil { + result = append(result, valueToString(value)) + } + } + return result +} + +func valueToString(value interface{}) string { + switch typed := value.(type) { + case string: + return typed + case json.Number: + return typed.String() + default: + return jsonNumberSafeString(typed) + } +} + +func jsonNumberSafeString(value interface{}) string { + bytes, err := json.Marshal(value) + if err != nil { + return "" + } + return string(bytes) } diff --git a/internal/handler/api/v1/automation_rule_handler_test.go b/internal/handler/api/v1/automation_rule_handler_test.go index 775ed1fe..8d7ca985 100644 --- a/internal/handler/api/v1/automation_rule_handler_test.go +++ b/internal/handler/api/v1/automation_rule_handler_test.go @@ -50,7 +50,7 @@ func (s *AutomationRuleHandlerTestSuite) SetupSuite() { r := gin.New() s.router = r -accountGroup := r.Group("/api/v1/accounts/:account_id") + accountGroup := r.Group("/api/v1/accounts/:account_id") { rulesGroup := accountGroup.Group("/automation_rules") { @@ -106,8 +106,11 @@ func (s *AutomationRuleHandlerTestSuite) TestList_Success() { var resp map[string]interface{} s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - rules := resp["automation_rules"].([]interface{}) + rules := resp["payload"].([]interface{}) s.Equal(2, len(rules)) + rule := rules[0].(map[string]interface{}) + s.Equal("status", rule["conditions"].([]interface{})[0].(map[string]interface{})["attribute_key"]) + s.Equal("equal_to", rule["conditions"].([]interface{})[0].(map[string]interface{})["filter_operator"]) } func (s *AutomationRuleHandlerTestSuite) TestList_Empty() { @@ -118,7 +121,7 @@ func (s *AutomationRuleHandlerTestSuite) TestList_Empty() { var resp map[string]interface{} s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - rules := resp["automation_rules"].([]interface{}) + rules := resp["payload"].([]interface{}) s.Equal(0, len(rules)) } @@ -137,6 +140,12 @@ func (s *AutomationRuleHandlerTestSuite) TestGet_Success() { req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/accounts/1/automation_rules/%d", rule.ID), nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) + + var resp map[string]interface{} + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + payload := resp["payload"].(map[string]interface{}) + s.Equal("TestRule", payload["name"]) + s.Equal(float64(1), payload["account_id"]) } func (s *AutomationRuleHandlerTestSuite) TestGet_InvalidID() { @@ -155,18 +164,32 @@ func (s *AutomationRuleHandlerTestSuite) TestGet_NotFound() { // --- Create tests --- func (s *AutomationRuleHandlerTestSuite) TestCreate_Success() { - body := `{"event_name":"conversation_created","name":"New Rule","active":true,"conditions":[{"attribute":"status","filter_operator":"equal","values":["open"],"query_operator":"and"}],"actions":[{"action_name":"assign_team","action_params":{"team_id":1}}]}` + body := `{"event_name":"conversation_created","name":"New Rule","active":true,"conditions":[{"attribute_key":"status","filter_operator":"equal_to","values":["open"],"query_operator":"and"}],"actions":[{"action_name":"assign_team","action_params":[1]}]}` w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/automation_rules", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) - s.Equal(http.StatusCreated, w.Code) + s.Equal(http.StatusOK, w.Code) var resp map[string]interface{} s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - data := resp["data"].(map[string]interface{}) - s.Equal("New Rule", data["name"]) - s.Equal(float64(1), data["account_id"]) + s.Equal("New Rule", resp["name"]) + s.Equal(float64(1), resp["account_id"]) + s.NotContains(resp, "data") + s.NotContains(resp, "success") + condition := resp["conditions"].([]interface{})[0].(map[string]interface{}) + s.Equal("status", condition["attribute_key"]) + s.Equal("equal_to", condition["filter_operator"]) + s.NotContains(condition, "attribute") + action := resp["actions"].([]interface{})[0].(map[string]interface{}) + s.Equal([]interface{}{float64(1)}, action["action_params"]) + + var saved automation.AutomationRule + s.NoError(s.db.First(&saved, uint(resp["id"].(float64))).Error) + s.Equal(uint(1), saved.AccountID) + s.Equal("status", saved.Conditions[0].Attribute) + s.Equal("equal", saved.Conditions[0].FilterOperator) + s.Equal(float64(1), saved.Actions[0].ActionParams["team_id"]) } func (s *AutomationRuleHandlerTestSuite) TestCreate_InvalidJSON() { @@ -190,12 +213,25 @@ func (s *AutomationRuleHandlerTestSuite) TestCreate_InvalidAccountID() { func (s *AutomationRuleHandlerTestSuite) TestUpdate_Success() { rule := s.createRule(1, "conversation_created", "OldName", true) - body := fmt.Sprintf(`{"event_name":"message_created","name":"UpdatedName","active":false,"conditions":[{"attribute":"status","filter_operator":"equal","values":["resolved"],"query_operator":"and"}],"actions":[{"action_name":"send_message","action_params":{"message":"Hello"}}]}`) + body := `{"event_name":"message_created","name":"UpdatedName","active":false,"conditions":[{"attribute_key":"status","filter_operator":"equal_to","values":["resolved"],"query_operator":"and"}],"actions":[{"action_name":"send_message","action_params":["Hello"]}]}` w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/accounts/1/automation_rules/%d", rule.ID), bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) + + var resp map[string]interface{} + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + payload := resp["payload"].(map[string]interface{}) + s.Equal("UpdatedName", payload["name"]) + s.Equal(float64(1), payload["account_id"]) + s.Equal(false, payload["active"]) + s.Equal([]interface{}{"Hello"}, payload["actions"].([]interface{})[0].(map[string]interface{})["action_params"]) + + var saved automation.AutomationRule + s.NoError(s.db.First(&saved, rule.ID).Error) + s.Equal(uint(1), saved.AccountID) + s.Equal("UpdatedName", saved.Name) } func (s *AutomationRuleHandlerTestSuite) TestUpdate_InvalidID() { @@ -216,13 +252,12 @@ func (s *AutomationRuleHandlerTestSuite) TestUpdate_InvalidJSON() { } func (s *AutomationRuleHandlerTestSuite) TestUpdate_NotFound() { - body := `{"name":"Updated","active":true}` + body := `{"name":"Updated","event_name":"message_created","conditions":[],"actions":[],"active":true}` w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPut, "/api/v1/accounts/1/automation_rules/9999", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) - // Service wraps "record not found" error → handleServiceError returns 500 - s.Equal(http.StatusUnprocessableEntity, w.Code) + s.Equal(http.StatusNotFound, w.Code) } // --- Delete tests --- @@ -233,10 +268,7 @@ func (s *AutomationRuleHandlerTestSuite) TestDelete_Success() { req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/v1/accounts/1/automation_rules/%d", rule.ID), nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) - - var resp map[string]interface{} - s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - s.Equal(true, resp["deleted"]) + s.Empty(w.Body.String()) } func (s *AutomationRuleHandlerTestSuite) TestDelete_InvalidID() { @@ -247,11 +279,10 @@ func (s *AutomationRuleHandlerTestSuite) TestDelete_InvalidID() { } func (s *AutomationRuleHandlerTestSuite) TestDelete_NotFound() { - // GORM Delete on non-existent ID returns nil error — handler returns 200 w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodDelete, "/api/v1/accounts/1/automation_rules/9999", nil) s.router.ServeHTTP(w, req) - s.Equal(http.StatusOK, w.Code) + s.Equal(http.StatusNotFound, w.Code) } // --- Clone tests --- @@ -261,11 +292,11 @@ func (s *AutomationRuleHandlerTestSuite) TestClone_Success() { w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/accounts/1/automation_rules/%d/clone", rule.ID), nil) s.router.ServeHTTP(w, req) - s.Equal(http.StatusCreated, w.Code) + s.Equal(http.StatusOK, w.Code) var resp map[string]interface{} s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - data := resp["data"].(map[string]interface{}) + data := resp["payload"].(map[string]interface{}) s.Equal("OriginalRule (copy)", data["name"]) s.Equal(false, data["active"]) } @@ -297,7 +328,7 @@ func (s *AutomationRuleHandlerTestSuite) TestToggleActive_Success() { var resp map[string]interface{} s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - s.Equal(true, resp["active"]) + s.Equal(true, resp["payload"].(map[string]interface{})["active"]) } func (s *AutomationRuleHandlerTestSuite) TestToggleActive_SetInactive() { @@ -312,7 +343,7 @@ func (s *AutomationRuleHandlerTestSuite) TestToggleActive_SetInactive() { var resp map[string]interface{} s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - s.Equal(false, resp["active"]) + s.Equal(false, resp["payload"].(map[string]interface{})["active"]) } func (s *AutomationRuleHandlerTestSuite) TestToggleActive_InvalidID() { @@ -338,10 +369,9 @@ func (s *AutomationRuleHandlerTestSuite) TestToggleActive_NotFound() { req := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/automation_rules/9999/toggle_active", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) - // ToggleActive updates via GORM Updates — no error for non-existent, just 0 rows affected - s.Equal(http.StatusOK, w.Code) + s.Equal(http.StatusNotFound, w.Code) } func TestAutomationRuleHandlerTestSuite(t *testing.T) { suite.Run(t, new(AutomationRuleHandlerTestSuite)) -} \ No newline at end of file +}