feat(macros): align chatwoot macro 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: `feat(automation): deliver retryable external actions`.
|
||||
- Latest documentation checkpoint: this checkpoint, recorded with the B9.1d implementation.
|
||||
- Worktree status at this implementation checkpoint: B9.1d retryable webhook/email transcript delivery is implemented; next active slice is B9.2 macro CRUD and execution parity.
|
||||
- Latest implementation checkpoint: `feat(macros): align chatwoot macro payloads`.
|
||||
- Latest documentation checkpoint: this checkpoint, recorded with the B9.2 macro implementation.
|
||||
- Worktree status at this implementation checkpoint: B9.2 macro CRUD payloads and execute side effects are implemented; next active slice is B10 audit/custom roles/remaining limits unless B9.3 delayed/durable worker scheduling is pulled forward.
|
||||
- `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`.
|
||||
@@ -44,10 +44,9 @@ Next ordered checkpoints:
|
||||
|
||||
| Order | Slice | Required outcome | Primary verification |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | B9.2 | Macro CRUD and macro execute side effects match Chatwoot frontend expectations. | Macro handler/service tests that reload conversations/messages after execution. |
|
||||
| 2 | B10 | Audit, CustomRole, and remaining InboxLimit surfaces enforce Chatwoot admin behavior. | Permission matrix tests, audit writer/list tests, inbox/account limit tests. |
|
||||
| 3 | B11 | Captain/Copilot enterprise screens have real persistence and safe LLM feature gates. | Captain/Copilot handler/service fixtures and disabled-state tests. |
|
||||
| 4 | B12 | Reused Chatwoot frontend smoke runs repeatably against GoChat. | Checked smoke command plus gap report under `docs/parity/`. |
|
||||
| 1 | B10 | Audit, CustomRole, and remaining InboxLimit surfaces enforce Chatwoot admin behavior. | Permission matrix tests, audit writer/list tests, inbox/account limit tests. |
|
||||
| 2 | B11 | Captain/Copilot enterprise screens have real persistence and safe LLM feature gates. | Captain/Copilot handler/service fixtures and disabled-state tests. |
|
||||
| 3 | B12 | Reused Chatwoot frontend smoke runs repeatably against GoChat. | Checked smoke command plus gap report under `docs/parity/`. |
|
||||
|
||||
## Execution Snapshot
|
||||
|
||||
@@ -57,7 +56,7 @@ Next ordered checkpoints:
|
||||
| Phase 1 | Meilisearch search engine | Review | B6 payload parity, optional live gate, and DB-fallback hardening are implemented; an actual live Meilisearch run is optional and environment-dependent |
|
||||
| Phase 2 | Route and controller parity audit | Doing | Ruby/Bundler unavailable, so Chatwoot route extraction currently uses static `routes.rb` fallback |
|
||||
| Phase 3 | Data and serializer parity | Doing | JSON fixture coverage is partial and still endpoint-family based |
|
||||
| Phase 4 | Enterprise feature completion | Doing | B7 and B8 are in Review; B9 automation/macros are active next |
|
||||
| Phase 4 | Enterprise feature completion | Doing | B7, B8, and frontend-critical B9 automation/macros are in Review; B10 audit/custom roles/remaining limits is the next active slice |
|
||||
| Phase 5 | Background jobs and integrations | Planned | durable worker choice and job parity are open |
|
||||
| Phase 6 | Core placeholder burn-down | Doing | account/contact/conversation/message/inbox placeholder groups remain broad |
|
||||
| Phase 7 | Verification harness | Planned | search live gate and reused-frontend smoke harness are not complete |
|
||||
@@ -140,12 +139,13 @@ This ledger records the committed parity checkpoints that future slices should b
|
||||
| `feat(automation): record rule execution outcomes` | Completed B9.1c observability/parity guard: rule evaluations now record skipped condition outcomes, condition failures, event names, aggregate action counts, and per-action success/failure results. Matching rules continue executing in ID order without stop-on-match, matching the current Chatwoot listener loop, and failed actions do not prevent later actions from running. | `go test ./internal/automation -run 'AutomationRuleService_MatchAndExecute\|ExecutionLogService' -count=1`; `go test ./internal/automation -count=1`; `go test ./internal/handler/webhook -run 'Incoming\|Webhook' -count=1`; `go test ./internal/service -run 'Conversation.*Automation\|Message.*Automation\|ConversationService' -count=1`; `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 830`. | Continue B9.1d retryable webhook/email transcript actions, then B9.2 macro payload/execute parity. |
|
||||
| `docs: land automation delivery plan` | Recorded the complete B9.1d retryable external-action landing contract: webhook payload/delivery, transcript mailer boundary, retry/timeout behavior, action-result observability fields, B9.3 durable-worker deferral, and exit commands. It also refreshed stale B9 tracker entries that still pointed at B9.1b. | Documentation-only checkpoint; `git diff --check` before commit. | Start B9.1d implementation, then continue B9.2 macro CRUD/execute parity. |
|
||||
| `feat(automation): deliver retryable external actions` | Completed B9.1d external action delivery: automation webhook actions now build Chatwoot-style `automation_event.<event>` payloads and deliver through a timeout-bound 3-attempt HTTP boundary; email transcript actions now split comma recipients, build transcript subject/body, deliver through a fakeable SMTP/mailer boundary, and both action types write delivery metadata into `action_results`. Chatwoot array params now round-trip `send_webhook_event` URLs. | `go test ./internal/automation -run 'ActionService\|AutomationRuleService_MatchAndExecute\|ExecutionLogService' -count=1`; `go test ./internal/automation -count=1`; `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 830`. | Continue B9.2 macro CRUD and macro execute side-effect parity; durable queued worker scheduling remains B9.3/Phase 5. |
|
||||
| `feat(macros): align chatwoot macro payloads` | Completed B9.2 macro CRUD/execute parity for the reused frontend: macro list/show/create/update now return Chatwoot `{ payload }` serializers with string visibility and nested created/updated agents, create/update accept Chatwoot array action params, public macro permissions follow `MacroPolicy`, delete/execute return empty `200 OK`, and execute resolves `conversation_ids` by account-scoped display ID before mutating conversations. | `go test ./internal/handler/api/v1 -run Macro -count=1`; `go test ./internal/automation -run Macro -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 B10 audit/custom roles/remaining limits; keep B9.3 durable delayed worker scheduling as Phase 5 follow-up. |
|
||||
|
||||
## 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.2 macro CRUD and macro execution parity now that B9.1a CRUD payloads, B9.1b listener coverage, B9.1c execution outcome logs, and B9.1d retryable external actions are implemented.
|
||||
Next implementation slice: start B10 audit/custom roles/remaining limit parity now that B9 automation and macro frontend-critical API/action behavior is in Review. B9.3 delayed/durable worker scheduling remains a named Phase 5 follow-up.
|
||||
|
||||
| Step | Required result | Reference source | Verification |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -163,7 +163,8 @@ Next implementation slice: start B9.2 macro CRUD and macro execution parity now
|
||||
| N12 | Keep B9.1b automation listener trigger coverage as current event baseline. | `AutomationRuleListener`, `BaseListener`, conversation/message service dispatch, provider webhook persister. | Done by `feat(automation): align rule trigger coverage`; reference listener events, skip rules, changed attributes, and provider message reachability are covered. |
|
||||
| N13 | Keep B9.1c execution outcome logging as current automation observability baseline. | Chatwoot automation listener/action service rescue behavior and Go execution log service. | Done by `feat(automation): record rule execution outcomes`; skipped/failed/success action results, event names, and no-stop-on-match behavior are covered. |
|
||||
| N14 | Keep B9.1d external action delivery boundary as current action-delivery baseline. | Chatwoot `AutomationRules::ActionService`, `WebhookJob`, transcript mailer path, current Go action service. | Done by `feat(automation): deliver retryable external actions`; webhook/email transcript actions are timeout-bound, retryable, observable in action results, and covered by fake HTTP/mailer tests. |
|
||||
| N15 | Update this tracker after every implementation checkpoint. | This document. | `git diff --check`; `go test ./...` for Go changes. |
|
||||
| N15 | Keep B9.2 macro CRUD/execute parity as current macro baseline. | Chatwoot `MacrosController`, `Macro`, `MacrosExecutionJob`, `Macros::ExecutionService`, dashboard macro API/store. | Done by `feat(macros): align chatwoot macro payloads`; frontend payloads, visibility/authorization, display-ID execution, and real conversation/message/label/status side effects are covered. |
|
||||
| N16 | Update this tracker after every implementation checkpoint. | This document. | `git diff --check`; `go test ./...` for Go changes. |
|
||||
|
||||
Current B2 profile checkpoint:
|
||||
|
||||
@@ -385,9 +386,9 @@ Upcoming enterprise task boards:
|
||||
| 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, event names, listener skip rules, event data, execution outcomes, and external action delivery. | Chatwoot automation rule controllers/models, `AutomationRuleListener`, action services/jobs, and dashboard automation builder. | Handler/service/listener tests for CRUD, validation, event mapping, skip rules, changed attributes, provider-dispatched events, execution logs, and retry metadata. | Review; B9.1a done by `3403770`, B9.1b done by `feat(automation): align rule trigger coverage`, B9.1c done by `4e28559`, B9.1d done by `feat(automation): deliver retryable external actions` |
|
||||
| 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.2 | 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. | Review; frontend-critical CRUD/execute done by `feat(macros): align chatwoot macro payloads` |
|
||||
| B9 | B9.3 | Add delayed action scheduling and durable worker parity after the B9.1d synchronous retry boundary lands. | Chatwoot automation jobs, delayed action handling, and Phase 5 worker plan. | Worker tests cover queued delayed actions, retries, 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 |
|
||||
| B9 | B9.4 | Review deeper macro attachments/files and durable queued execution once the worker path is selected. | Chatwoot macro attachments and `MacrosExecutionJob`. | Attachment/file tests or explicit durable-worker split. | Todo |
|
||||
| B10 | B10.1 | Align audit log serializer, filters, pagination, actor/request metadata, and admin route behavior. | Chatwoot enterprise audit controllers/models. | Audit list tests plus representative mutation writer tests. | Todo |
|
||||
| B10 | B10.2 | Align CustomRole permission keys, account-user role resolution, deletion nullification, and authorization failure payloads. | Chatwoot custom role controllers/policies and permission constants. | Permission matrix tests for admin/non-admin/custom-role access. | Todo |
|
||||
| B10 | B10.3 | Review remaining InboxLimit/account-limit create-path enforcement outside capacity policies. | Chatwoot enterprise account/inbox limit policies. | Inbox/channel create tests for over-limit behavior and frontend-readable errors. | Todo |
|
||||
@@ -416,8 +417,28 @@ B9 automation and macro execution breakdown:
|
||||
| B9.1b | Implement real rule trigger coverage for Chatwoot automation events: conversation created/updated/opened/resolved and message created. Provider webhook persistence must dispatch into those same conversation/message events. `contact_updated` is not a current Chatwoot automation trigger and stays out unless the local reference/frontend proves otherwise. | `reference/chatwoot/app/listeners/automation_rule_listener.rb`, `reference/chatwoot/app/listeners/base_listener.rb`, existing `channel.Dispatcher` events. | Listener tests prove matching rules execute once per event with account/inbox filters, changed attributes, provider-event reachability, and Chatwoot skip rules. | Done by `feat(automation): align rule trigger coverage` |
|
||||
| B9.1c | Persist execution logs and stop-on-match behavior, including failed action records. The current Chatwoot listener runs every matching rule, so Go explicitly preserves no-stop-on-match behavior and records it in tests. | `reference/chatwoot/app/listeners/automation_rule_listener.rb`, `reference/chatwoot/app/services/automation_rules/action_service.rb`, Go execution log service. | Service tests cover success, skipped, failed/partial, per-action result records, and all-matching-rules execution. | Done by `feat(automation): record rule execution outcomes` |
|
||||
| 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. | Done by `feat(automation): deliver retryable external actions` |
|
||||
| B9.2a | Align macro CRUD payloads, visibility rules, and validation. | Chatwoot macros controller/model and dashboard macro client. | Handler tests for raw macro payloads, ownership, account scoping, and delete status. | Todo |
|
||||
| B9.2b | Execute macro actions against real conversations: labels, status, priority, assignee, team, custom attributes, private notes, and attachments where supported. | Chatwoot macro execution service and conversation/message controllers. | Macro execute tests reload conversation/message rows and assert Chatwoot-shaped response payloads. | Todo |
|
||||
| B9.2a | Align macro CRUD payloads, visibility rules, and validation. | Chatwoot macros controller/model and dashboard macro client. | Handler tests for raw macro payloads, ownership, account scoping, and delete status. | Done by `feat(macros): align chatwoot macro payloads` |
|
||||
| B9.2b | Execute macro actions against real conversations: labels, status, priority, assignee, team, custom attributes, private notes, and attachments where supported. | Chatwoot macro execution service and conversation/message controllers. | Macro execute tests reload conversation/message rows and assert Chatwoot-shaped response payloads. | Review by `feat(macros): align chatwoot macro payloads`; labels/status/messages/private notes/display-ID execution covered, deeper file attachments remain B9.4/Phase 5 |
|
||||
|
||||
B9.2 current checkpoint:
|
||||
|
||||
- Macro `index`, `show`, `create`, and `update` now return Chatwoot `{ payload }` envelopes with `id`, `name`, string `visibility`, `account_id`, array-shaped `actions`, and nested `created_by`/`updated_by` agent payloads.
|
||||
- Macro create/update accept Chatwoot dashboard action arrays such as `action_params: ["self"]` and normalize them into the existing action execution map without exposing local numeric visibility in responses.
|
||||
- Macro show/update/delete/execute are account-scoped. Public macro update/delete now require administrator role; personal macro update/delete require the author; show/execute allow public macros or the author, matching `MacroPolicy`.
|
||||
- Macro delete and execute now return empty `200 OK`, matching `MacrosController#destroy/#execute`.
|
||||
- Macro execute accepts `conversation_ids`, resolves them as account-scoped conversation display IDs like `MacrosExecutionJob`, and executes real action side effects through `ActionService`: labels, status, outgoing messages, and private notes are covered by handler tests.
|
||||
- Deeper ActiveStorage-backed macro file attachment behavior and durable queued execution stay visible under B9.4/B9.3 instead of being hidden under this API checkpoint.
|
||||
|
||||
B9.2 verification:
|
||||
|
||||
```bash
|
||||
env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -run Macro -count=1
|
||||
env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/automation -run Macro -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
|
||||
```
|
||||
|
||||
B9.1a automation-rule landing contract:
|
||||
|
||||
@@ -693,7 +714,7 @@ Work proceeds top-down unless a failing test or frontend blocker forces a narrow
|
||||
| B6 | Meilisearch live-shape review. | Chatwoot frontend search usage and search controllers. | Meilisearch-backed response fixtures plus optional live integration gate. | Review |
|
||||
| B7 | SLA and assignment capacity. | Chatwoot enterprise SLA and assignment policy behavior. | Unit/integration tests for SLA state, breach, assignment capacity, policy selection. | Review |
|
||||
| B8 | CSAT account/public/send completion. | Chatwoot CSAT survey responses, reports, downloads, listeners. | Metrics/list/review/download/send idempotency tests. | Review |
|
||||
| B9 | Automation/macros durable side effects. | Chatwoot automation/macro services and jobs. | Action execution, logs, webhook/email transcript retry tests. | Doing |
|
||||
| B9 | Automation/macros durable side effects. | Chatwoot automation/macro services and jobs. | Action execution, logs, webhook/email transcript retry tests. | Review |
|
||||
| B10 | Audit, CustomRole, InboxLimit. | Chatwoot enterprise admin behavior and policies. | Authorization, audit emission, limits enforcement, admin payload fixtures. | Todo |
|
||||
| B11 | Captain/Copilot deep behavior. | Chatwoot Captain/Copilot controllers, services, frontend clients. | Assistant/tool/document/scenario/copilot thread/task tests and feature gates. | Todo |
|
||||
| B12 | Frontend smoke harness. | `reference/chatwoot` frontend. | Repeatable smoke command and checked gap report. | Todo |
|
||||
@@ -705,7 +726,7 @@ Remaining slice landing plan:
|
||||
| B6 | Compare current Meilisearch document fields and global/entity search payloads against Chatwoot frontend consumers. | Add optional live Meilisearch integration gate, tighten account-scoped filters, and document DB fallback as development-only. | Done only after mocked tests and live-shape review prove search payloads work without DB-only assumptions. |
|
||||
| B7 | Done: assignment capacity enforcement, SLA policy CRUD payload parity, applied-SLA conversation persistence, idempotent SLA breach lifecycle, applied-SLA report/list payload parity, and SLA miss notification fan-out. | Capacity-aware assignment policy binding review and durable SLA job scheduling remain broader Phase 5/P4.9 follow-ups, not hidden SLA gaps. | Review after `d23f3f0`; move to Done only after assignment policy binding review is either implemented or split out of B7. |
|
||||
| B8 | Done: account-side CSAT list/metrics/review-note payload parity, public submit/update idempotency, resolve-triggered generic survey message send, and CSV download filters/columns. | Channel-specific WhatsApp/Twilio template send hooks and durable queueing are tracked under Phase 5. | Review after `b36cf07`; move to Done only after channel-specific send hooks are either implemented or formally split as external integration scope. |
|
||||
| B9 | B9.2 macro CRUD and macro execution side effects. | Macro payload parity, action mutation parity, delayed actions, and durable queued worker follow-ups. | Done only after Chatwoot listener events are covered, actions mutate real conversations, external actions are retryable/observable, macro flows pass frontend payload tests, and durable worker gaps are either implemented or explicitly split. |
|
||||
| B9 | Done: B9.1 automation rule CRUD/listener/log/external-action parity and B9.2 macro frontend CRUD/execute side effects. | Delayed actions, durable queued worker scheduling, and deeper macro attachment/file parity remain named B9.3/B9.4 follow-ups. | Review after `feat(macros): align chatwoot macro payloads`; move to Done only after durable worker/attachment gaps are implemented or formally split out. |
|
||||
| B10 | Audit list payload and audit writer boundary for representative mutating core resources. | CustomRole permission-key parity, AccountUser permission resolution, InboxLimit enforcement in inbox/channel creation paths. | Done only after authorization tests prove admin-only surfaces and non-admin denial shapes match Chatwoot. |
|
||||
| B11 | Captain Assistant CRUD, inbox binding, responses, documents, and custom tools payload fixtures. | Copilot threads/messages/tasks, playground/tool-call behavior, document sync/embedding feature gates, streaming fallback. | Done only after LLM-dependent behavior is either implemented behind config or safely stubbed with frontend-compatible disabled states. |
|
||||
| B12 | Boot reused Chatwoot frontend against GoChat auth/profile/inbox/conversation/contact flows. | Add smoke paths for widget init/message, public CSAT, reports, and enterprise screens as B7-B11 land. | Done only after the smoke command is repeatable and writes a checked gap report. |
|
||||
@@ -987,7 +1008,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` | 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.8 | Automation and macros | `internal/automation/*`, `internal/handler/api/v1/automation_rule_handler.go`, `internal/handler/api/v1/macro_handler.go` | Automation-rule CRUD/listener/log/external-action delivery and macro CRUD/display-ID execution parity are implemented; durable delayed actions and deeper macro attachment/file parity remain B9.3/B9.4 follow-ups. | Review |
|
||||
| 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:
|
||||
@@ -998,7 +1019,7 @@ Enterprise work package breakdown:
|
||||
| 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 | CRUD payload parity, condition/action parity, event listener coverage, delayed actions, execution logs, no-stop-on-match behavior, webhook and transcript actions. | B9.1a CRUD, B9.1b listener coverage, B9.1c execution outcome logging, and B9.1d retryable webhook/email transcript delivery are covered. Durable delayed actions remain B9.3/Phase 5. | Review |
|
||||
| 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 |
|
||||
| Macros | Macro CRUD, availability by account/user, execute side effects, validation, audit/log output. | CRUD payloads, visibility/authorization, display-ID execution, labels/status/messages/private notes, and empty execute/delete responses are covered by `feat(macros): align chatwoot macro payloads`; deeper attachment files and durable queue remain follow-ups. | Review |
|
||||
| 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 |
|
||||
| Inbox limits | Account/inbox limit models, create/update enforcement, UI-readable limit responses, admin overrides. | Chatwoot nested capacity-limit API and assignment-path enforcement are covered; account-level create limits remain for later review. | Review |
|
||||
@@ -1229,3 +1250,4 @@ Verification milestone gates:
|
||||
- 2026-06-05: B9.1c execution outcome checkpoint committed as `feat(automation): record rule execution outcomes`; automation evaluations now persist event names, skipped condition outcomes, condition errors, per-action success/failure JSON, and aggregate action counts. Failed actions continue to later actions, and every matching rule still executes in ID order to match the current Chatwoot listener's no-stop-on-match loop. Focused automation tests, automation package tests, relevant service/webhook regressions, full `go test ./...`, and `git diff --check` passed. Next slice is B9.1d retryable webhook/email transcript action delivery.
|
||||
- 2026-06-05: B9.1d planning checkpoint prepared as `docs: land automation delivery plan`; the active tracker now points at B9.1d, records the Chatwoot webhook/transcript reference contracts, scopes the retryable/testable action delivery boundary, names B9.3 durable-worker follow-up work, and lists the required focused/full verification commands before macro work starts.
|
||||
- 2026-06-05: B9.1d external action delivery checkpoint prepared as `feat(automation): deliver retryable external actions`; `send_webhook_event` now builds Chatwoot-style automation webhook payloads and delivers through a timeout-bound retryable HTTP boundary, `send_email_transcript` parses recipients and sends generated transcript emails through a fakeable SMTP boundary, and automation execution logs now record delivery type, target, attempts, response code/body, retryability, and failure errors. Focused automation tests, automation package tests, full `go test ./...`, and `git diff --check` passed. Next slice is B9.2 macro CRUD and execution parity.
|
||||
- 2026-06-05: B9.2 macro payload/execute checkpoint prepared as `feat(macros): align chatwoot macro payloads`; macro CRUD now returns Chatwoot `{ payload }` serializers with string visibility and nested agent authors, accepts frontend array action params, enforces `MacroPolicy`-style public/personal access, deletes and executes with empty `200 OK`, and resolves execute `conversation_ids` by display ID before applying labels/status/messages/private notes. Focused macro handler/service tests, handler and automation package tests, full `go test ./...`, and `git diff --check` passed. Next slice is B10 audit/custom roles/remaining limits while B9.3/B9.4 worker and attachment depth remain tracked follow-ups.
|
||||
|
||||
@@ -3,8 +3,11 @@ package automation
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MacroService provides CRUD + execution for macros.
|
||||
@@ -22,7 +25,19 @@ func NewMacroService(db DBProvider) *MacroService {
|
||||
// GetByID retrieves a macro by ID.
|
||||
func (s *MacroService) GetByID(ctx context.Context, id uint) (*Macro, error) {
|
||||
var macro Macro
|
||||
if err := s.db.DB().WithContext(ctx).First(¯o, id).Error; err != nil {
|
||||
if err := s.db.DB().WithContext(ctx).Preload("CreatedBy").Preload("UpdatedBy").First(¯o, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ¯o, nil
|
||||
}
|
||||
|
||||
// GetByIDForAccount retrieves a macro scoped to an account.
|
||||
func (s *MacroService) GetByIDForAccount(ctx context.Context, accountID, id uint) (*Macro, error) {
|
||||
var macro Macro
|
||||
if err := s.db.DB().WithContext(ctx).
|
||||
Preload("CreatedBy").Preload("UpdatedBy").
|
||||
Where("account_id = ?", accountID).
|
||||
First(¯o, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ¯o, nil
|
||||
@@ -39,7 +54,7 @@ func (s *MacroService) ListByAccount(ctx context.Context, accountID uint, userID
|
||||
query = query.Where("visibility = ? OR (visibility = ? AND created_by_id = ?)",
|
||||
MacroVisibilityGlobal, MacroVisibilityPersonal, userID)
|
||||
|
||||
if err := query.Order("name ASC").Find(¯os).Error; err != nil {
|
||||
if err := query.Preload("CreatedBy").Preload("UpdatedBy").Order("id ASC").Find(¯os).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return macros, nil
|
||||
@@ -47,13 +62,23 @@ func (s *MacroService) ListByAccount(ctx context.Context, accountID uint, userID
|
||||
|
||||
// Create creates a new macro.
|
||||
func (s *MacroService) Create(ctx context.Context, macro *Macro) error {
|
||||
if strings.TrimSpace(macro.Name) == "" {
|
||||
return fmt.Errorf("name is required")
|
||||
}
|
||||
if macro.Actions == nil {
|
||||
macro.Actions = Actions{}
|
||||
}
|
||||
macro.Active = true
|
||||
// Use Select to force all fields including zero-value bool Active=false.
|
||||
// Without Select, GORM skips zero-value fields and uses column defaults.
|
||||
// Callers should explicitly set Active=true when creating new macros.
|
||||
return s.db.DB().WithContext(ctx).Select(
|
||||
if err := s.db.DB().WithContext(ctx).Select(
|
||||
"AccountID", "Name", "Actions", "Visibility", "Active",
|
||||
"CreatedByID", "UpdatedByID",
|
||||
).Create(macro).Error
|
||||
).Create(macro).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.DB().WithContext(ctx).Preload("CreatedBy").Preload("UpdatedBy").First(macro, macro.ID).Error
|
||||
}
|
||||
|
||||
// Update updates an existing macro.
|
||||
@@ -61,11 +86,36 @@ func (s *MacroService) Update(ctx context.Context, macro *Macro) error {
|
||||
return s.db.DB().WithContext(ctx).Save(macro).Error
|
||||
}
|
||||
|
||||
// UpdateForAccount updates an existing macro within account scope while preserving ownership.
|
||||
func (s *MacroService) UpdateForAccount(ctx context.Context, accountID uint, macro *Macro) (*Macro, error) {
|
||||
existing, err := s.GetByIDForAccount(ctx, accountID, macro.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing.Name = macro.Name
|
||||
existing.Actions = macro.Actions
|
||||
existing.Visibility = macro.Visibility
|
||||
existing.UpdatedByID = macro.UpdatedByID
|
||||
if err := s.Update(ctx, existing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetByIDForAccount(ctx, accountID, existing.ID)
|
||||
}
|
||||
|
||||
// Delete deletes a macro by ID.
|
||||
func (s *MacroService) Delete(ctx context.Context, id uint) error {
|
||||
return s.db.DB().WithContext(ctx).Delete(&Macro{}, id).Error
|
||||
}
|
||||
|
||||
// DeleteForAccount deletes a macro within account scope.
|
||||
func (s *MacroService) DeleteForAccount(ctx context.Context, accountID, id uint) error {
|
||||
macro, err := s.GetByIDForAccount(ctx, accountID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.DB().WithContext(ctx).Delete(macro).Error
|
||||
}
|
||||
|
||||
// Clone creates a copy of a macro with "(copy)" appended to the name.
|
||||
// Reference: Chatwoot Macro clone — duplicates actions, resets ID, stamps creator.
|
||||
func (s *MacroService) Clone(ctx context.Context, id uint) (*Macro, error) {
|
||||
@@ -75,11 +125,11 @@ func (s *MacroService) Clone(ctx context.Context, id uint) (*Macro, error) {
|
||||
}
|
||||
|
||||
cloned := &Macro{
|
||||
AccountID: original.AccountID,
|
||||
Name: original.Name + " (copy)",
|
||||
Actions: original.Actions,
|
||||
Visibility: original.Visibility,
|
||||
Active: original.Active,
|
||||
AccountID: original.AccountID,
|
||||
Name: original.Name + " (copy)",
|
||||
Actions: original.Actions,
|
||||
Visibility: original.Visibility,
|
||||
Active: original.Active,
|
||||
CreatedByID: original.CreatedByID,
|
||||
UpdatedByID: original.UpdatedByID,
|
||||
}
|
||||
@@ -140,6 +190,65 @@ func (s *MacroService) Execute(ctx context.Context, accountID uint, conversation
|
||||
return s.recordExecution(ctx, macroID, conversationID, userID)
|
||||
}
|
||||
|
||||
// ExecuteForDisplayIDs runs a macro against account-scoped conversation display IDs.
|
||||
// Reference: Chatwoot MacrosExecutionJob resolves account.conversations by display_id.
|
||||
func (s *MacroService) ExecuteForDisplayIDs(ctx context.Context, accountID uint, macroID uint, displayIDs []uint, userID uint) error {
|
||||
macro, err := s.GetByIDForAccount(ctx, accountID, macroID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(displayIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var conversations []model.Conversation
|
||||
if err := s.db.DB().WithContext(ctx).
|
||||
Where("account_id = ? AND display_id IN ?", accountID, displayIDs).
|
||||
Find(&conversations).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(conversations) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
actionSvc := NewActionService(s.db)
|
||||
for _, conversation := range conversations {
|
||||
applogger.L().Infof("executing macro %d (%s) on conversation %d by user %d", macro.ID, macro.Name, conversation.ID, userID)
|
||||
for _, action := range macro.Actions {
|
||||
params := map[string]interface{}{}
|
||||
for key, value := range action.ActionParams {
|
||||
params[key] = value
|
||||
}
|
||||
params["_source_user_id"] = userID
|
||||
resolvedAction := Action{ActionName: action.ActionName, ActionParams: params}
|
||||
if err := actionSvc.Execute(ctx, accountID, conversation.ID, resolvedAction, ActionSourceMacro, userID); err != nil {
|
||||
applogger.L().Errorf("macro action %s failed for macro %d on conversation %d: %v", action.ActionName, macroID, conversation.ID, err)
|
||||
}
|
||||
}
|
||||
if err := s.recordExecution(ctx, macroID, conversation.ID, userID); err != nil && err != gorm.ErrRecordNotFound {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CanAccess matches Chatwoot MacroPolicy for show/update/destroy/execute.
|
||||
func (s *MacroService) CanAccess(macro *Macro, userID uint, role string, action string) bool {
|
||||
if macro == nil {
|
||||
return false
|
||||
}
|
||||
if action == "show" || action == "execute" {
|
||||
return macro.Visibility == MacroVisibilityGlobal || macro.CreatedByID == userID
|
||||
}
|
||||
if action == "update" || action == "destroy" {
|
||||
if macro.Visibility == MacroVisibilityGlobal {
|
||||
return role == "administrator" || role == "super_admin"
|
||||
}
|
||||
return macro.CreatedByID == userID
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// recordExecution logs that a macro was executed on a conversation by a user.
|
||||
func (s *MacroService) recordExecution(ctx context.Context, macroID, conversationID, userID uint) error {
|
||||
record := &MacroExecution{
|
||||
@@ -152,10 +261,10 @@ func (s *MacroService) recordExecution(ctx context.Context, macroID, conversatio
|
||||
|
||||
// MacroExecution records a macro execution event for audit trail.
|
||||
type MacroExecution struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
MacroID uint `gorm:"index;not null" json:"macro_id"`
|
||||
ConversationID uint `gorm:"index;not null" json:"conversation_id"`
|
||||
ExecutedByID uint `gorm:"index;not null" json:"executed_by_id"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
MacroID uint `gorm:"index;not null" json:"macro_id"`
|
||||
ConversationID uint `gorm:"index;not null" json:"conversation_id"`
|
||||
ExecutedByID uint `gorm:"index;not null" json:"executed_by_id"`
|
||||
}
|
||||
|
||||
func (MacroExecution) TableName() string { return "macro_executions" }
|
||||
func (MacroExecution) TableName() string { return "macro_executions" }
|
||||
|
||||
@@ -121,6 +121,8 @@ type Macro struct {
|
||||
Active bool `gorm:"not null" json:"active"` // enable/disable macro execution
|
||||
CreatedByID uint `gorm:"index;not null" json:"created_by_id"`
|
||||
UpdatedByID uint `gorm:"index;not null" json:"updated_by_id"`
|
||||
CreatedBy *model.User `gorm:"foreignKey:CreatedByID" json:"created_by,omitempty"`
|
||||
UpdatedBy *model.User `gorm:"foreignKey:UpdatedByID" json:"updated_by,omitempty"`
|
||||
}
|
||||
|
||||
func (Macro) TableName() string { return "macros" }
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/automation"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/pkg/response"
|
||||
)
|
||||
|
||||
@@ -38,26 +40,35 @@ func (h *MacroHandler) List(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"payload": macros,
|
||||
"payload": serializeMacros(macros),
|
||||
})
|
||||
}
|
||||
|
||||
// Get retrieves a single macro by ID.
|
||||
// GET /api/v1/accounts/:account_id/macros/:id
|
||||
func (h *MacroHandler) Get(c *gin.Context) {
|
||||
accountID, err := parseUintParam(c, "account_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
macroID, err := parseUintParam(c, "macro_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
macro, svcErr := h.svc.GetByID(c.Request.Context(), macroID)
|
||||
macro, svcErr := h.svc.GetByIDForAccount(c.Request.Context(), accountID, macroID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
if !h.svc.CanAccess(macro, getUserID(c), getRole(c), "show") {
|
||||
abortMacroUnauthorized(c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, macro)
|
||||
c.JSON(http.StatusOK, gin.H{"payload": serializeMacro(macro)})
|
||||
}
|
||||
|
||||
// Create creates a new macro.
|
||||
@@ -69,8 +80,8 @@ func (h *MacroHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var macro automation.Macro
|
||||
if err := c.ShouldBindJSON(¯o); err != nil {
|
||||
macro, err := bindMacroRequest(c)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -86,25 +97,40 @@ func (h *MacroHandler) Create(c *gin.Context) {
|
||||
macro.Visibility = automation.MacroVisibilityPersonal
|
||||
}
|
||||
|
||||
if svcErr := h.svc.Create(c.Request.Context(), ¯o); svcErr != nil {
|
||||
if svcErr := h.svc.Create(c.Request.Context(), macro); svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
response.Created(c, macro)
|
||||
c.JSON(http.StatusOK, gin.H{"payload": serializeMacro(macro)})
|
||||
}
|
||||
|
||||
// Update updates an existing macro.
|
||||
// PUT /api/v1/accounts/:account_id/macros/:id
|
||||
func (h *MacroHandler) Update(c *gin.Context) {
|
||||
accountID, err := parseUintParam(c, "account_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
macroID, err := parseUintParam(c, "macro_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
var macro automation.Macro
|
||||
if err := c.ShouldBindJSON(¯o); err != nil {
|
||||
existing, svcErr := h.svc.GetByIDForAccount(c.Request.Context(), accountID, macroID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
if !h.svc.CanAccess(existing, getUserID(c), getRole(c), "update") {
|
||||
abortMacroUnauthorized(c)
|
||||
return
|
||||
}
|
||||
|
||||
macro, err := bindMacroRequest(c)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -112,39 +138,52 @@ func (h *MacroHandler) Update(c *gin.Context) {
|
||||
macro.ID = macroID
|
||||
macro.UpdatedByID = getUserID(c)
|
||||
|
||||
if svcErr := h.svc.Update(c.Request.Context(), ¯o); svcErr != nil {
|
||||
updated, svcErr := h.svc.UpdateForAccount(c.Request.Context(), accountID, macro)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, macro)
|
||||
c.JSON(http.StatusOK, gin.H{"payload": serializeMacro(updated)})
|
||||
}
|
||||
|
||||
// Delete soft-deletes a macro.
|
||||
// DELETE /api/v1/accounts/:account_id/macros/:id
|
||||
func (h *MacroHandler) Delete(c *gin.Context) {
|
||||
accountID, err := parseUintParam(c, "account_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
macroID, err := parseUintParam(c, "macro_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
if svcErr := h.svc.Delete(c.Request.Context(), macroID); svcErr != nil {
|
||||
macro, svcErr := h.svc.GetByIDForAccount(c.Request.Context(), accountID, macroID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
if !h.svc.CanAccess(macro, getUserID(c), getRole(c), "destroy") {
|
||||
abortMacroUnauthorized(c)
|
||||
return
|
||||
}
|
||||
|
||||
if svcErr := h.svc.DeleteForAccount(c.Request.Context(), accountID, macroID); svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": strconv.FormatUint(uint64(macroID), 10),
|
||||
"deleted": true,
|
||||
})
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// Execute runs a macro on a conversation.
|
||||
// POST /api/v1/accounts/:account_id/macros/:id/execute
|
||||
// Reference: Chatwoot MacrosController#execute — MacroService.Execute returns error only
|
||||
func (h *MacroHandler) Execute(c *gin.Context) {
|
||||
macroID, err := parseUintParam(c, "id")
|
||||
macroID, err := parseUintParam(c, "macro_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid macro id")
|
||||
return
|
||||
@@ -156,26 +195,30 @@ func (h *MacroHandler) Execute(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
ConversationID uint `json:"conversation_id"`
|
||||
}
|
||||
var body macroExecuteRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "conversation_id is required")
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "conversation_ids is required")
|
||||
return
|
||||
}
|
||||
displayIDs := body.DisplayIDs()
|
||||
|
||||
userID := getUserID(c)
|
||||
macro, svcErr := h.svc.GetByIDForAccount(c.Request.Context(), accountID, macroID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
if !h.svc.CanAccess(macro, userID, getRole(c), "execute") {
|
||||
abortMacroUnauthorized(c)
|
||||
return
|
||||
}
|
||||
|
||||
userID := getUserID(c)
|
||||
|
||||
if svcErr := h.svc.Execute(c.Request.Context(), accountID, body.ConversationID, macroID, userID); svcErr != nil {
|
||||
if svcErr := h.svc.ExecuteForDisplayIDs(c.Request.Context(), accountID, macroID, displayIDs, userID); svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"macro_id": strconv.FormatUint(uint64(macroID), 10),
|
||||
"conversation_id": strconv.FormatUint(uint64(body.ConversationID), 10),
|
||||
})
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// Clone duplicates a macro.
|
||||
@@ -230,7 +273,122 @@ func (h *MacroHandler) ToggleActive(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": strconv.FormatUint(uint64(macroID), 10),
|
||||
"id": macroID,
|
||||
"active": req.Active,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type macroActionRequest struct {
|
||||
ActionName string `json:"action_name"`
|
||||
ActionParams json.RawMessage `json:"action_params"`
|
||||
}
|
||||
|
||||
type macroRequest struct {
|
||||
Name string `json:"name"`
|
||||
Visibility interface{} `json:"visibility"`
|
||||
Actions []macroActionRequest `json:"actions"`
|
||||
}
|
||||
|
||||
func bindMacroRequest(c *gin.Context) (*automation.Macro, error) {
|
||||
var req macroRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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.Macro{
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Visibility: parseMacroVisibility(req.Visibility),
|
||||
Actions: actions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseMacroVisibility(value interface{}) automation.MacroVisibility {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if strings.EqualFold(v, "global") {
|
||||
return automation.MacroVisibilityGlobal
|
||||
}
|
||||
case float64:
|
||||
if int(v) == int(automation.MacroVisibilityGlobal) {
|
||||
return automation.MacroVisibilityGlobal
|
||||
}
|
||||
case int:
|
||||
if v == int(automation.MacroVisibilityGlobal) {
|
||||
return automation.MacroVisibilityGlobal
|
||||
}
|
||||
}
|
||||
return automation.MacroVisibilityPersonal
|
||||
}
|
||||
|
||||
type macroExecuteRequest struct {
|
||||
ConversationIDs []uint `json:"conversation_ids"`
|
||||
ConversationID uint `json:"conversation_id"`
|
||||
}
|
||||
|
||||
func (r macroExecuteRequest) DisplayIDs() []uint {
|
||||
if len(r.ConversationIDs) > 0 {
|
||||
return r.ConversationIDs
|
||||
}
|
||||
if r.ConversationID > 0 {
|
||||
return []uint{r.ConversationID}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func serializeMacros(macros []automation.Macro) []gin.H {
|
||||
result := make([]gin.H, 0, len(macros))
|
||||
for i := range macros {
|
||||
result = append(result, serializeMacro(¯os[i]))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func serializeMacro(macro *automation.Macro) gin.H {
|
||||
item := gin.H{
|
||||
"id": macro.ID,
|
||||
"name": macro.Name,
|
||||
"visibility": macroVisibilityString(macro.Visibility),
|
||||
"account_id": macro.AccountID,
|
||||
"actions": serializeAutomationActions(macro.Actions),
|
||||
}
|
||||
if macro.CreatedBy != nil && macro.CreatedBy.ID != 0 {
|
||||
item["created_by"] = serializeMacroAgent(macro.CreatedBy)
|
||||
}
|
||||
if macro.UpdatedBy != nil && macro.UpdatedBy.ID != 0 {
|
||||
item["updated_by"] = serializeMacroAgent(macro.UpdatedBy)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func macroVisibilityString(visibility automation.MacroVisibility) string {
|
||||
if visibility == automation.MacroVisibilityGlobal {
|
||||
return "global"
|
||||
}
|
||||
return "personal"
|
||||
}
|
||||
|
||||
func serializeMacroAgent(user *model.User) gin.H {
|
||||
availability := "offline"
|
||||
if user.Available {
|
||||
availability = "online"
|
||||
}
|
||||
return gin.H{
|
||||
"id": user.ID,
|
||||
"name": user.Name,
|
||||
"available_name": user.Name,
|
||||
"email": user.Email,
|
||||
"availability_status": availability,
|
||||
"thumbnail": user.AvatarURL,
|
||||
}
|
||||
}
|
||||
|
||||
func abortMacroUnauthorized(c *gin.Context) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "You are not authorized to do this action"})
|
||||
}
|
||||
|
||||
@@ -39,7 +39,10 @@ func (s *MacroHandlerTestSuite) SetupSuite() {
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.User{}, &automation.Macro{}))
|
||||
s.Require().NoError(db.AutoMigrate(
|
||||
&model.Account{}, &model.User{}, &model.AccountUser{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, &model.Message{},
|
||||
&automation.Macro{}, &automation.MacroExecution{}, &automation.ConversationLabel{}, &automation.ConversationMute{},
|
||||
))
|
||||
s.db = db
|
||||
|
||||
macroSvc := automation.NewMacroService(&testMacroDBProvider{db: db})
|
||||
@@ -82,8 +85,8 @@ func (s *MacroHandlerTestSuite) TestCreate_Success() {
|
||||
|
||||
body := map[string]interface{}{
|
||||
"name": "test-macro",
|
||||
"visibility": 0,
|
||||
"actions": []map[string]interface{}{{"action_name": "assign_agent", "action_params": map[string]interface{}{"agent_id": "1"}}},
|
||||
"visibility": "global",
|
||||
"actions": []map[string]interface{}{{"action_name": "assign_agent", "action_params": []interface{}{"self"}}},
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
|
||||
@@ -92,7 +95,17 @@ func (s *MacroHandlerTestSuite) TestCreate_Success() {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusCreated, w.Code)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
payload := resp["payload"].(map[string]interface{})
|
||||
assert.Equal(s.T(), "test-macro", payload["name"])
|
||||
assert.Equal(s.T(), "global", payload["visibility"])
|
||||
createdBy := payload["created_by"].(map[string]interface{})
|
||||
assert.Equal(s.T(), float64(s.user.ID), createdBy["id"])
|
||||
actions := payload["actions"].([]interface{})
|
||||
action := actions[0].(map[string]interface{})
|
||||
assert.Equal(s.T(), []interface{}{"self"}, action["action_params"])
|
||||
}
|
||||
|
||||
func (s *MacroHandlerTestSuite) TestCreate_BadRequest() {
|
||||
@@ -105,4 +118,107 @@ func (s *MacroHandlerTestSuite) TestCreate_BadRequest() {
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MacroHandlerTestSuite) TestUpdateDeleteAuthorizationAndPayload() {
|
||||
admin := &model.User{Name: "Admin", Email: "macro-admin@example.com", Role: "administrator"}
|
||||
s.Require().NoError(s.db.Create(admin).Error)
|
||||
agent := &model.User{Name: "Agent", Email: "macro-agent@example.com", Role: "agent"}
|
||||
s.Require().NoError(s.db.Create(agent).Error)
|
||||
macro := &automation.Macro{
|
||||
AccountID: s.account.ID,
|
||||
Name: "public macro",
|
||||
Actions: automation.Actions{},
|
||||
Visibility: automation.MacroVisibilityGlobal,
|
||||
Active: true,
|
||||
CreatedByID: admin.ID,
|
||||
UpdatedByID: admin.ID,
|
||||
}
|
||||
s.Require().NoError(s.db.Create(macro).Error)
|
||||
|
||||
r := gin.New()
|
||||
r.PUT("/api/v1/accounts/:account_id/macros/:macro_id", func(c *gin.Context) {
|
||||
c.Set("user_id", agent.ID)
|
||||
c.Set("role", "agent")
|
||||
c.Next()
|
||||
}, s.handler.Update)
|
||||
|
||||
body := []byte(`{"name":"agent edit","visibility":"global","actions":[]}`)
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("PUT", fmt.Sprintf("/api/v1/accounts/%d/macros/%d", s.account.ID, macro.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(s.T(), http.StatusUnauthorized, w.Code)
|
||||
|
||||
r = gin.New()
|
||||
r.PUT("/api/v1/accounts/:account_id/macros/:macro_id", func(c *gin.Context) {
|
||||
c.Set("user_id", admin.ID)
|
||||
c.Set("role", "administrator")
|
||||
c.Next()
|
||||
}, s.handler.Update)
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("PUT", fmt.Sprintf("/api/v1/accounts/%d/macros/%d", s.account.ID, macro.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
payload := resp["payload"].(map[string]interface{})
|
||||
assert.Equal(s.T(), "agent edit", payload["name"])
|
||||
assert.Equal(s.T(), "global", payload["visibility"])
|
||||
}
|
||||
|
||||
func (s *MacroHandlerTestSuite) TestExecute_UsesConversationDisplayIDsAndMutatesConversation() {
|
||||
admin := &model.User{Name: "Exec Admin", Email: "macro-exec-admin@example.com", Role: "administrator"}
|
||||
s.Require().NoError(s.db.Create(admin).Error)
|
||||
inbox := &model.Inbox{AccountID: s.account.ID, Name: "Macro Inbox", ChannelType: "web"}
|
||||
s.Require().NoError(s.db.Create(inbox).Error)
|
||||
contact := &model.Contact{AccountID: s.account.ID, Name: "Macro Contact", Email: "macro-contact@example.com"}
|
||||
s.Require().NoError(s.db.Create(contact).Error)
|
||||
displayID := uint(444)
|
||||
conversation := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open", Priority: "low", ChannelType: "web", Channel: "web_widget"}
|
||||
s.Require().NoError(s.db.Create(conversation).Error)
|
||||
macro := &automation.Macro{
|
||||
AccountID: s.account.ID,
|
||||
Name: "execute macro",
|
||||
Actions: automation.Actions{
|
||||
{ActionName: "add_label", ActionParams: map[string]interface{}{"labels": []string{"vip"}}},
|
||||
{ActionName: "change_status", ActionParams: map[string]interface{}{"status": "resolved"}},
|
||||
{ActionName: "send_message", ActionParams: map[string]interface{}{"content": "Hello from macro"}},
|
||||
{ActionName: "add_private_note", ActionParams: map[string]interface{}{"content": "Internal macro note"}},
|
||||
},
|
||||
Visibility: automation.MacroVisibilityGlobal,
|
||||
Active: true,
|
||||
CreatedByID: admin.ID,
|
||||
UpdatedByID: admin.ID,
|
||||
}
|
||||
s.Require().NoError(s.db.Create(macro).Error)
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/accounts/:account_id/macros/:macro_id/execute", func(c *gin.Context) {
|
||||
c.Set("user_id", admin.ID)
|
||||
c.Set("role", "administrator")
|
||||
c.Next()
|
||||
}, s.handler.Execute)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/macros/%d/execute", s.account.ID, macro.ID), bytes.NewBufferString(`{"conversation_ids":[444]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
assert.Empty(s.T(), w.Body.String())
|
||||
|
||||
var reloaded model.Conversation
|
||||
s.Require().NoError(s.db.First(&reloaded, conversation.ID).Error)
|
||||
assert.Equal(s.T(), "resolved", reloaded.Status)
|
||||
var label automation.ConversationLabel
|
||||
s.Require().NoError(s.db.Where("conversation_id = ? AND label = ?", conversation.ID, "vip").First(&label).Error)
|
||||
var messages []model.Message
|
||||
s.Require().NoError(s.db.Where("conversation_id = ?", conversation.ID).Order("id ASC").Find(&messages).Error)
|
||||
s.Require().Len(messages, 2)
|
||||
assert.Equal(s.T(), "Hello from macro", messages[0].Content)
|
||||
assert.Equal(s.T(), admin.ID, *messages[0].SenderID)
|
||||
assert.False(s.T(), messages[0].Private)
|
||||
assert.Equal(s.T(), "Internal macro note", messages[1].Content)
|
||||
assert.True(s.T(), messages[1].Private)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user