feat(automation): align automation rule payloads
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" }
|
||||
func (ConversationForFilter) TableName() string { return "conversations" }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user