feat(automation): close delayed action parity

This commit is contained in:
2026-06-05 21:42:10 +08:00
parent 43aa2f81eb
commit ddf2694ed3
7 changed files with 464 additions and 39 deletions
+56 -36
View File
@@ -16,10 +16,10 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc
## Current Baseline
- Current tracking checkpoint: 2026-06-05 after `a48b9cd feat(reports): derive analytics aggregates`, with this implementation checkpoint prepared as `feat(reports): add analytics timeseries rollups`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): add analytics timeseries rollups`.
- Current tracking checkpoint: 2026-06-05 after `43aa2f8 feat(reports): add analytics timeseries rollups`, with this implementation checkpoint prepared as `feat(automation): close delayed action parity`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(automation): close delayed action parity`.
- Latest documentation-only checkpoint: `2923aae docs: land parity execution tracker`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`.
- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, v2 history handling, and no-LLM fallback; B11.3c adds the fakeable Captain document sync backend gate with disabled, failed, and fake-success states; B11.3d aligns Captain task request/response payloads, no-provider disabled states, follow-up context, suggestion persistence, and Copilot message tool-call key validation; B11.3e aligns Captain stream DTOs/disabled SSE fallbacks and Copilot push-event payload shapes; B12.1 adds the reusable GoChat server/seed entrypoint plus a Meilisearch-first reused Chatwoot frontend smoke harness and report; B12.2a adds API smoke assertions for auth/profile, inbox, conversation/messages, contact/company, widget config/message, and public CSAT; B12.2b adds a zero-dependency Chrome DevTools browser smoke that loads the reused Chatwoot login and dashboard entrypoints through Vite and checks browser auth/dashboard API requests; B12.3a adds enterprise API smoke assertions for SLA reports/download, CSAT reports/download, automation/macros, audit/custom roles, capacity, Captain, and Copilot; B12.3b adds reused-frontend enterprise browser route navigation for SLA, CSAT, automation, macros, audit logs, custom roles, capacity, Captain, and Copilot request coverage; P5.1 adds the PostgreSQL-backed durable `background_jobs` model/migration plus WorkerPool enqueue, schedule, retry/backoff, dead-letter, idempotency, stale-lock recovery, and focused tests; P5.2 wires `channel.Dispatcher` and `dispatch.EventDispatcher` async paths into durable event jobs with worker replay tests; P5.3 queues Meilisearch write-side index/delete jobs for conversations, messages, contacts, companies, and articles while keeping search reads Meilisearch-first; P5.4 queues automation webhook and email transcript side effects as durable jobs while preserving fakeable delivery boundaries; P5.5 queues Chatwoot-style macro execute fan-out through durable `automation:macro_execution` jobs; P5.6 queues resolve-triggered CSAT survey sends and WhatsApp/Twilio CSAT template creation through durable jobs; P5.7 queues Chatwoot enterprise SLA account scans and applied-SLA evaluation jobs through the durable worker; P5.8 queues Chatwoot-style contact export artifact generation through durable `contact:export` jobs; P5.9 queues normalized provider inbound message persistence/dispatch through durable `webhook:incoming_message_persist` jobs; P5.10 queues Chatwoot `SendReplyJob`-style outbound message delivery through durable `message:send_reply` jobs and provider delivery-status/read-receipt updates through durable webhook status jobs; P5.11 queues Captain document sync, crawl/parser, schedule-sync, response-builder, embedding-update, Copilot response, and Captain conversation response-builder work through durable jobs; P5.12 queues scheduled item fan-out, one-off campaigns, snoozed conversation reopening, account auto-resolution, widget/public message status updates, and account conversation bulk actions through durable jobs; P5.13a replaces the live report, bot report, conversation summary, inbox-label matrix, first-response distribution, and outgoing-message placeholder responses with persisted conversation/message/reporting-event aggregations; P5.13b routes `GET /reports` to Chatwoot-style metric timeseries, adds lazy rollup freshness/idempotency, registers durable `reporting:rollup_day` jobs, and makes rollup replacement hard-delete soft-deleted rows before recompute. Next active implementation slice is B9.3 delayed automation action verification, followed by Phase 2/3 drift and Phase 6 placeholder audits.
- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, v2 history handling, and no-LLM fallback; B11.3c adds the fakeable Captain document sync backend gate with disabled, failed, and fake-success states; B11.3d aligns Captain task request/response payloads, no-provider disabled states, follow-up context, suggestion persistence, and Copilot message tool-call key validation; B11.3e aligns Captain stream DTOs/disabled SSE fallbacks and Copilot push-event payload shapes; B12.1 adds the reusable GoChat server/seed entrypoint plus a Meilisearch-first reused Chatwoot frontend smoke harness and report; B12.2a adds API smoke assertions for auth/profile, inbox, conversation/messages, contact/company, widget config/message, and public CSAT; B12.2b adds a zero-dependency Chrome DevTools browser smoke that loads the reused Chatwoot login and dashboard entrypoints through Vite and checks browser auth/dashboard API requests; B12.3a adds enterprise API smoke assertions for SLA reports/download, CSAT reports/download, automation/macros, audit/custom roles, capacity, Captain, and Copilot; B12.3b adds reused-frontend enterprise browser route navigation for SLA, CSAT, automation, macros, audit logs, custom roles, capacity, Captain, and Copilot request coverage; P5.1 adds the PostgreSQL-backed durable `background_jobs` model/migration plus WorkerPool enqueue, schedule, retry/backoff, dead-letter, idempotency, stale-lock recovery, and focused tests; P5.2 wires `channel.Dispatcher` and `dispatch.EventDispatcher` async paths into durable event jobs with worker replay tests; P5.3 queues Meilisearch write-side index/delete jobs for conversations, messages, contacts, companies, and articles while keeping search reads Meilisearch-first; P5.4 queues automation webhook and email transcript side effects as durable jobs while preserving fakeable delivery boundaries; P5.5 queues Chatwoot-style macro execute fan-out through durable `automation:macro_execution` jobs; P5.6 queues resolve-triggered CSAT survey sends and WhatsApp/Twilio CSAT template creation through durable jobs; P5.7 queues Chatwoot enterprise SLA account scans and applied-SLA evaluation jobs through the durable worker; P5.8 queues Chatwoot-style contact export artifact generation through durable `contact:export` jobs; P5.9 queues normalized provider inbound message persistence/dispatch through durable `webhook:incoming_message_persist` jobs; P5.10 queues Chatwoot `SendReplyJob`-style outbound message delivery through durable `message:send_reply` jobs and provider delivery-status/read-receipt updates through durable webhook status jobs; P5.11 queues Captain document sync, crawl/parser, schedule-sync, response-builder, embedding-update, Copilot response, and Captain conversation response-builder work through durable jobs; P5.12 queues scheduled item fan-out, one-off campaigns, snoozed conversation reopening, account auto-resolution, widget/public message status updates, and account conversation bulk actions through durable jobs; P5.13a replaces the live report, bot report, conversation summary, inbox-label matrix, first-response distribution, and outgoing-message placeholder responses with persisted conversation/message/reporting-event aggregations; P5.13b routes `GET /reports` to Chatwoot-style metric timeseries, adds lazy rollup freshness/idempotency, registers durable `reporting:rollup_day` jobs, and makes rollup replacement hard-delete soft-deleted rows before recompute; B9.3 verifies the current reference exposes no delayed automation action params, preserves scheduled-item coverage under P5.12, and completes remaining automation action side effects by queueing `send_email_to_team` plus applying enterprise `add_sla`. Next active implementation slice is Phase 2/3 drift audit, followed by Phase 6 placeholder audit.
- `go test ./...` passes.
- Route dump succeeds with `TOTAL: 833` after adding `GET /api/v1/accounts/:account_id/reports` for the Chatwoot reports index path.
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
@@ -45,18 +45,17 @@ Next ordered checkpoints:
| Order | Slice | Required outcome | Primary verification |
| --- | --- | --- | --- |
| 1 | B9.3 delayed automation actions | Confirm current-reference delayed automation params and queue any still-synchronous scheduled action execution. | Automation worker fixtures for schedule time, retry, idempotency, and observable failure. |
| 2 | Phase 2/3 audit pass | Route/controller/serializer drift found by B12 or new reference inspection is captured as named slices, not free-form TODOs. | Regenerated route parity artifacts and fixture-backed serializer tests. |
| 3 | Phase 6 placeholder burn-down | Remaining account/contact/conversation/message/inbox placeholder handlers are either real Chatwoot-compatible flows or explicitly tracked as unsupported reference gaps. | `rg` placeholder audit, route smoke, and endpoint-family tests. |
| 4 | B12 optional live smoke | Run the checked smoke harness in a full PostgreSQL/Redis/Meilisearch/Vite/Chrome environment and turn failures into named slices. | `docs/parity/frontend_smoke_report.md` pass/fail entries linked to owners. |
| 1 | Phase 2/3 audit pass | Route/controller/serializer drift found by B12 or new reference inspection is captured as named slices, not free-form TODOs. | Regenerated route parity artifacts and fixture-backed serializer tests. |
| 2 | Phase 6 placeholder burn-down | Remaining account/contact/conversation/message/inbox placeholder handlers are either real Chatwoot-compatible flows or explicitly tracked as unsupported reference gaps. | `rg` placeholder audit, route smoke, and endpoint-family tests. |
| 3 | B12 optional live smoke | Run the checked smoke harness in a full PostgreSQL/Redis/Meilisearch/Vite/Chrome environment and turn failures into named slices. | `docs/parity/frontend_smoke_report.md` pass/fail entries linked to owners. |
## Handoff Contract
This checkpoint is intended to make the development plan complete enough to track without reading Hermes notes first.
- P5.13b scheduled/cached analytics is now in Review. P5.13a replaced the most visible report placeholders with persisted aggregations; P5.13b adds report rollup freshness/idempotency, durable day rollup jobs, and `/reports` timeseries index parity. B12 has repeatable API/browser/enterprise smoke harnesses in Review; optional live failures should be converted into named slices instead of blocking Phase 5 job work.
- P5.13b scheduled/cached analytics is now in Review. B9.3 is also in Review: current `reference/chatwoot` automation actions expose no delayed action parameter; scheduled root jobs are already covered by P5.12; this checkpoint completes remaining automation action side effects with durable team-email jobs and enterprise `add_sla`. B12 has repeatable API/browser/enterprise smoke harnesses in Review; optional live failures should be converted into named slices instead of blocking Phase 5 job work.
- The Hermes search plan is fully represented by Phase 1/B6. Future search changes must be Meilisearch-first and must not reintroduce production DB fallback.
- The Hermes automation/macro/CSAT plan is fully represented by B8/B9 and Phase 5. Durable delayed execution remains visible Phase 5/B9.3 work if the current reference exposes explicit delayed action params; channel-specific template delivery is in Review.
- The Hermes automation/macro/CSAT plan is fully represented by B8/B9 and Phase 5. Durable delayed execution is closed for the current reference because no explicit delayed action params exist; future delayed-action changes should be opened only from new reference or smoke evidence. Channel-specific template delivery is in Review.
- Enterprise scope is fixed: SLA, Audit, CustomRole, AgentCapacity, Captain/Copilot, CSAT, InboxLimit, automation, macros, assignment policies, and related limits/workflows are in scope; SSO/SAML/LDAP/OIDC are out of scope.
- Every future commit must update one of: the Current Baseline, Commit Ledger, active B-slice board, Phase 5 backlog, B12 smoke report link, or Progress Log.
@@ -64,7 +63,7 @@ Open work after the current checkpoint:
| Area | Next concrete action | Tracking location | Done boundary |
| --- | --- | --- | --- |
| Phase 5 jobs | Finish the B9.3 delayed automation action check on top of the committed durable worker. | `Phase 5: Background Jobs And Integrations` | Automation tests prove real scheduling, retry, idempotency, and observable failures, or reference inspection proves no delayed action params remain. |
| Phase 5 jobs | Keep B9.3 in Review and open only evidence-backed drift slices for new async gaps. | `Phase 5: Background Jobs And Integrations` | Automation tests prove remaining action side effects and reference inspection proves no delayed action params remain. |
| B12 smoke | Run optional live API/browser/enterprise smoke in a full PostgreSQL/Redis/Meilisearch/Vite environment and convert failures into named slices. | `B12 reused frontend verification breakdown` | `docs/parity/frontend_smoke_report.md` records checked pass/fail results and maps failures to slices. |
| Phase 2/3 drift | Expand tracked route/serializer fixtures when B12 exposes frontend-critical gaps. | `Phase 2`, `Phase 3`, `docs/parity/` | Route parity remains 0 missing for tracked frontend routes; serializers have reference fixtures. |
| Phase 6 placeholders | Re-run placeholder audit and burn down any frontend-reachable stub. | `Phase 6: Core Product Placeholder Burn-down` | Stub list has no reused-frontend critical path without a named owner. |
@@ -78,7 +77,7 @@ Open work after the current checkpoint:
| 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, B8, B9, B10, and B11 are in Review; B12 reused frontend smoke harnesses exist and optional live runs can expose follow-up slices |
| Phase 5 | Background jobs and integrations | Doing | P5.1/P5.2/P5.3/P5.4/P5.5/P5.6/P5.7/P5.8/P5.9/P5.10/P5.11/P5.12 durable worker, event dispatch, search indexing, automation delivery, macro, CSAT survey/template, SLA scan, contact export, inbound webhook persistence, outbound/provider delivery status, Captain document sync/crawl/response/embedding/Copilot/conversation responses, conversation maintenance, message status update, account bulk-action cores, and P5.13 analytics rollups/timeseries are in Review; the B9.3 delayed automation action check remains open |
| Phase 5 | Background jobs and integrations | Review | P5.1/P5.2/P5.3/P5.4/P5.5/P5.6/P5.7/P5.8/P5.9/P5.10/P5.11/P5.12 durable worker, event dispatch, search indexing, automation delivery, macro, CSAT survey/template, SLA scan, contact export, inbound webhook persistence, outbound/provider delivery status, Captain document sync/crawl/response/embedding/Copilot/conversation responses, conversation maintenance, message status update, account bulk-action cores, P5.13 analytics rollups/timeseries, and B9.3 delayed-action reference closure are in Review; further work should be named drift from smoke/reference evidence |
| Phase 6 | Core placeholder burn-down | Doing | account/contact/conversation/message/inbox placeholder groups remain broad |
| Phase 7 | Verification harness | Review | B12.1 boot/readiness, B12.2a API assertions, B12.2b browser smoke harness, B12.3a enterprise API assertions, and B12.3b enterprise browser route navigation exist; optional live Meilisearch/full-browser runs remain environment-dependent |
@@ -88,10 +87,10 @@ This table is the shortest authoritative handoff view. If an older lower section
| Priority | Workstream | Current state | Next checkpoint | Commit close rule |
| --- | --- | --- | --- | --- |
| 1 | B9.3 delayed automation actions | Macro fan-out, webhook/transcript delivery, CSAT jobs, and analytics rollups are durable; current-reference delayed automation action params still need a final check. | Queue any still-synchronous delayed automation action execution or close the row with reference evidence if no explicit delayed params exist. | Automation worker fixtures verify schedule time, retry, idempotency, and observable failure. |
| 2 | Phase 2/3 drift | Tracked route parity is 0 missing for the current critical set; serializer fixtures remain partial. | Expand route/serializer fixtures when smoke or reference inspection exposes drift. | Regenerate parity artifacts and add endpoint-family fixture tests. |
| 3 | Phase 6 placeholder audit | Widget/public/webhook critical placeholders are burned down; account/contact/conversation/message/inbox audit remains broad. | Run a fresh placeholder audit and assign every frontend-reachable stub to a tracked owner. | `rg` audit result is recorded and no reused-frontend blocker is ownerless. |
| 4 | B12 optional live smoke | API/browser/enterprise smoke commands are checked in; live runs need PostgreSQL, Redis, Meilisearch, Vite, and Chrome. | Run full live smoke when environment is available and map failures to the board. | `docs/parity/frontend_smoke_report.md` records pass/fail and linked owners. |
| 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current critical set; serializer fixtures remain partial. | Expand route/serializer fixtures when smoke or reference inspection exposes drift. | Regenerate parity artifacts and add endpoint-family fixture tests. |
| 2 | Phase 6 placeholder audit | Widget/public/webhook critical placeholders are burned down; account/contact/conversation/message/inbox audit remains broad. | Run a fresh placeholder audit and assign every frontend-reachable stub to a tracked owner. | `rg` audit result is recorded and no reused-frontend blocker is ownerless. |
| 3 | B12 optional live smoke | API/browser/enterprise smoke commands are checked in; live runs need PostgreSQL, Redis, Meilisearch, Vite, and Chrome. | Run full live smoke when environment is available and map failures to the board. | `docs/parity/frontend_smoke_report.md` records pass/fail and linked owners. |
| 4 | B9.3 delayed automation actions | Current reference exposes no delayed automation action params; scheduled-item work is already P5.12; `send_email_to_team` is durable and `add_sla` mutates conversation/applied SLA state. | Keep automation drift closed if future reference/smoke exposes delayed params or unsupported action shapes. | Automation worker/action fixtures verify queued team email replay, retry visibility through worker jobs, and SLA action idempotency. |
| 5 | P5.13 reports/analytics | P5.13a derives visible report aggregates from persisted rows; P5.13b adds lazy rollup freshness, durable day rollup jobs, and `/reports` metric timeseries. | Keep report drift closed as frontend smoke or reference inspection exposes additional metrics. | Report fixtures verify timeseries values, cache/freshness behavior, and no hidden placeholder JSON. |
## Open Checkpoint Contracts
@@ -111,9 +110,9 @@ These rows are the executable development plan from this point forward. A checkp
Checkpoint sequencing:
1. Run B9.3 delayed automation action verification next, now that macro/CSAT/report job families are durable.
2. Run Phase 2/3 and Phase 6 audits after each smoke failure or route/serializer change, not as a one-time cleanup.
3. Keep B12 live smoke optional until the full external stack is available, but every failed live smoke must become a named row in this table.
1. Run Phase 2/3 and Phase 6 audits after each smoke failure or route/serializer change, not as a one-time cleanup.
2. Keep B12 live smoke optional until the full external stack is available, but every failed live smoke must become a named row in this table.
3. Open future Phase 5 work only from concrete reference or smoke evidence now that the named durable worker backlog is in Review.
Commit discipline:
@@ -140,7 +139,8 @@ This ledger records the committed parity checkpoints that future slices should b
| Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- |
| `feat(reports): add analytics timeseries rollups` | Completes P5.13b for scheduled/cached analytics parity. `GET /api/v1/accounts/:account_id/reports` and v2 `/reports` now route to metric timeseries instead of summary, with support for account/inbox/agent/team/label dimensions, day/hour/week/month/year buckets, conversation/message/reporting-event metrics, and business-hours averages. Analytics summary/dimension/traffic reads call `EnsureRollupsForRange` for lazy freshness; `reporting:rollup_day` jobs provide durable day recompute; rollup replacement uses `Unscoped` delete so soft-deleted rows cannot violate uniqueness on recompute. | `go test ./internal/service -run 'Analytics' -count=1`; `go test ./internal/handler/api/v1 -run 'Analytics\|LiveReport' -count=1`; `go test ./internal/service ./internal/handler/api/v1 ./internal/router ./internal/worker ./internal/app -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./...`; `git diff --check`; full verification recorded in the P5.13 section. | P5.13 moves to Review; continue B9.3 delayed automation action verification, then Phase 2/3 drift and Phase 6 placeholder audits. |
| `feat(automation): close delayed action parity` | Closes B9.3 for the current reference. `reference/chatwoot` automation models, action service, enterprise action service, listener, and dashboard action components expose no configurable delayed action params, so no separate delayed automation scheduler is required beyond P5.12 scheduled items. The checkpoint completes remaining automation action side effects: `send_email_to_team` now queues durable `automation:team_email_delivery` jobs and replays through the fakeable mailer boundary, while enterprise `add_sla` attaches an account-scoped SLA policy and creates idempotent AppliedSLA targets. The automation serializer now preserves Chatwoot's object-shaped `send_email_to_team` params. | `go test ./internal/automation -run 'ActionService\|AutomationRuleService_MatchAndExecute\|ExecutionLogService' -count=1`; `go test ./internal/handler/api/v1 -run AutomationRule -count=1`; `go test ./internal/automation ./internal/handler/api/v1 ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`. | B9.3 and Phase 5 move to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, and optional B12 live smoke. |
| `feat(reports): add analytics timeseries rollups` | Completes P5.13b for scheduled/cached analytics parity. `GET /api/v1/accounts/:account_id/reports` and v2 `/reports` now route to metric timeseries instead of summary, with support for account/inbox/agent/team/label dimensions, day/hour/week/month/year buckets, conversation/message/reporting-event metrics, and business-hours averages. Analytics summary/dimension/traffic reads call `EnsureRollupsForRange` for lazy freshness; `reporting:rollup_day` jobs provide durable day recompute; rollup replacement uses `Unscoped` delete so soft-deleted rows cannot violate uniqueness on recompute. | `go test ./internal/service -run 'Analytics' -count=1`; `go test ./internal/handler/api/v1 -run 'Analytics\|LiveReport' -count=1`; `go test ./internal/service ./internal/handler/api/v1 ./internal/router ./internal/worker ./internal/app -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./...`; `git diff --check`; full verification recorded in the P5.13 section. | P5.13 moves to Review; B9.3 is completed by `feat(automation): close delayed action parity`; continue Phase 2/3 drift and Phase 6 placeholder audits. |
| `feat(reports): derive analytics aggregates` | Advances P5.13a by replacing frontend-visible analytics placeholder responses with persisted aggregations. Live report conversation metrics now count open/unattended/unassigned/pending conversations with team filtering; grouped live reports return assignee/team grouped counts; bot summary/metrics, conversation summary, inbox-label matrix, first-response distribution, and outgoing-message counts are derived from conversations, messages, labels, agent-bot bindings, and reporting events instead of fixed zero/empty JSON. | `go test ./internal/service -run 'Analytics' -count=1`; `go test ./internal/handler/api/v1 -run 'Analytics\|LiveReport' -count=1`; `go test ./internal/service ./internal/handler/api/v1 ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.13 section. | P5.13a moves to Review; continue P5.13b scheduled/cached rollup freshness and `/reports` timeseries index parity, then B9.3 delayed automation action check. |
| `feat(captain): queue copilot response jobs` | Completes P5.11c with durable Chatwoot `Captain::Copilot::ResponseJob` and `Captain::Conversation::ResponseBuilderJob` equivalents. Copilot thread/message creation now persists the user message and enqueues `captain:copilot_response` when a WorkerPool is configured, while worker replay reloads the account/user/thread/message scope and persists assistant replies through a fakeable backend or the existing no-provider fallback. Incoming pending conversation messages for Captain-enabled inboxes now enqueue `captain:conversation_response_builder`; replay collects public incoming/outgoing history, creates Captain outgoing replies, enqueues provider send-reply, and opens the conversation with a handoff message when the backend requests handoff. | `go test ./internal/service -run 'CopilotResponse\|CaptainConversation\|MessageService' -count=1`; `go test ./internal/service ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.11 section. | P5.11 moves to Review; continue P5.13 analytics aggregation, then Phase 2/3 drift and Phase 6 placeholder audits as smoke exposes gaps. |
| `feat(captain): queue response embedding jobs` | Advances P5.11b with durable Captain document response building and embedding update fan-out. Successful document sync/parser content updates enqueue `captain:document_response_builder`, worker replay resets only unedited document responses, preserves edited responses, creates approved `Captain::Document` assistant responses from a fakeable FAQ backend, and enqueues `captain:llm_update_embedding` jobs for created responses. Embedding replay reloads account-scoped responses, uses a fakeable embedding backend or configured LLM provider, and surfaces missing provider config as retryable worker failures. | `go test ./internal/service -run 'CaptainDocumentService\|EnqueueCaptainDocumentScheduleSyncs' -count=1`; `go test ./internal/service ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.11 section. | P5.11b moves to Review; continue P5.11c Copilot/conversation response jobs, then P5.13 analytics aggregation. |
@@ -252,9 +252,9 @@ This ledger records the committed parity checkpoints that future slices should b
Completed implementation slice: B11.3c now adds Captain document sync backend gates and observable disabled/failure/success states, building on B11.1a-B11.3b Captain/Copilot resource contracts.
Completed documentation slice: the remaining development plan is now fully landed in this tracker. B11.3 is split into concrete Captain playground, document sync/indexing, Copilot task/tool-call, and streaming/realtime checkpoints. B12 has a repeatable smoke-report contract. B9.3 delayed/durable worker scheduling and B9.4 macro attachment depth remain named Phase 5 follow-ups.
Completed documentation slice: the remaining development plan is fully landed in this tracker. B11.3 is split into concrete Captain playground, document sync/indexing, Copilot task/tool-call, and streaming/realtime checkpoints. B12 has a repeatable smoke-report contract. B9.3 is closed for the current reference and B9.4 macro attachment depth remains the named automation/macro follow-up.
Next implementation slice: start B12 reused frontend smoke. B11.3d covers rewrite/summarize/reply suggestion/label suggestion/follow-up request payloads, raw disabled states, persisted suggestion state, and reloadable Copilot message key validation; B11.3e now adds deterministic stream disabled states and Copilot push payload parity.
Next implementation slice: start the Phase 2/3 route/controller/serializer drift audit, then continue the Phase 6 placeholder burn-down. Optional B12 live smoke remains environment-dependent and should convert failures into named slice owners.
| Step | Required result | Reference source | Verification |
| --- | --- | --- | --- |
@@ -508,7 +508,7 @@ Upcoming enterprise task boards:
| 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 | 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.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 side effects, retries, and observable failures; reference audit proves no explicit delayed action params exist. | Review by `feat(automation): close delayed action parity`; current reference has no delayed action params, P5.12 covers scheduled items, `send_email_to_team` queues durable team notification jobs, and `add_sla` applies account-scoped SLA state idempotently |
| 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. | Done by `feat(audit): align chatwoot audit log payloads`; writer coverage continues in B10.2 |
| B10 | B10.2 | Add audit writer coverage for representative core and enterprise mutations: inbox, conversation assignment/status, SLA policy, capacity policy, custom role, automation, macro, CSAT review notes. | Chatwoot audit hooks and current Go service mutation points. | Mutation tests assert audit rows with actor, auditable type/id, account, IP/request metadata where available. | Done by `feat(audit): record enterprise mutations` and `feat(audit): cover operational mutations` |
@@ -550,7 +550,7 @@ B9.2 current checkpoint:
- 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.
- Deeper ActiveStorage-backed macro file attachment behavior stays visible under B9.4 instead of being hidden under this API checkpoint; durable macro execution is covered by P5.5.
B9.2 verification:
@@ -563,6 +563,24 @@ env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./...
git diff --check
```
B9.3 current checkpoint:
- Current `reference/chatwoot` automation rule model, listener, action service, enterprise action service, and dashboard action inputs expose no configurable delayed automation action params. No separate delayed automation scheduler is required beyond P5.12 scheduled item/root jobs unless a future reference or smoke pass proves a new param shape.
- `send_email_to_team` now accepts Chatwoot object-shaped `{ team_ids, message }` action params, normalizes frontend array values, and queues `automation:team_email_delivery` jobs when a WorkerPool is configured.
- Team-email worker replay reloads the account/rule/conversation scope and delivers generated transcript emails through the existing fakeable mailer boundary; no-worker construction keeps deterministic focused-test behavior.
- Enterprise `add_sla` now matches the current reference `Enterprise::ActionService#add_sla`: it only applies when the conversation has no SLA, validates an account-scoped policy, sets `conversation.sla_policy_id`, and creates idempotent `AppliedSLA` target rows.
- B9.3 and Phase 5 move to `Review`; deeper macro attachment/file parity remains B9.4, and future delayed-action work requires new reference or smoke evidence.
B9.3 verification:
```bash
env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/automation -run 'ActionService|AutomationRuleService_MatchAndExecute|ExecutionLogService' -count=1
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 ./internal/handler/api/v1 ./internal/worker ./internal/app -count=1
env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./...
git diff --check
```
B9.1a automation-rule landing contract:
| Area | Reference contract | Current Go gap | Landing requirement |
@@ -644,7 +662,7 @@ B9.1b current checkpoint:
- Listener context extraction now supports `event.ConversationID`, `event.Data["conversation_id"]`, `event.Data["conversation"]`, and `event.Data["message"]`, with account fallback from conversation/message payloads.
- Conversation update/status/priority paths now dispatch Chatwoot-style `changed_attributes` so `attribute_changed` automation conditions can match status, priority, and SLA policy changes.
- Conversation creation with an initial message now dispatches `message.created` with `ConversationID`, `ContactID`, message payload, and conversation payload; provider webhook persistence is covered by an automation regression proving incoming provider messages trigger `message_created` rules once.
- B9 remains `Doing` because B9.2 macro parity and B9.3 delayed/durable worker parity are still active.
- Historical checkpoint note: B9 stayed `Doing` here because B9.2/B9.3 were still active at this point; the active board above now treats B9.3 as Review and keeps only B9.4 macro attachment depth open.
B9.1b verification:
@@ -666,7 +684,7 @@ B9.1c current checkpoint:
- Condition matcher errors are logged as failed rule evaluations instead of disappearing into process logs only.
- Failed actions record per-action errors while later actions still run, matching Chatwoot `AutomationRules::ActionService#perform` rescue-and-continue behavior.
- The current Chatwoot listener loops through every matching rule; Go preserves this no-stop-on-match behavior and has regression coverage proving two matching rules both execute.
- B9 remains `Doing` because B9.2 macro parity and B9.3 delayed/durable worker parity are still active.
- Historical checkpoint note: B9 stayed `Doing` here because B9.2/B9.3 were still active at this point; the active board above now treats B9.3 as Review and keeps only B9.4 macro attachment depth open.
B9.1c verification:
@@ -687,7 +705,7 @@ B9.1d retryable external action landing contract:
| Webhook delivery retries | `reference/chatwoot/app/jobs/webhook_job.rb` delegates to `Webhooks::Trigger`; related webhook job paths retry retryable failures. | Go has webhook subscription delivery models/config, but automation actions are not timeout-bound or retryable. | Add timeout-bound HTTP delivery with configured max attempts/delay or a documented synchronous retry fallback until the durable worker slice lands. Tests must prove success, retry-to-success, and retry-exhausted failure metadata. |
| Email transcript action | Chatwoot automation calls the transcript mailer path used by conversation transcript delivery, splitting recipient inputs and respecting email delivery availability. | `handleSendEmailTranscript` is also a TODO/no-op. | Add a transcript mailer interface that can be faked in tests, parses Chatwoot array/comma recipient params, sends per recipient with timeout/retry metadata, and records failures without blocking later actions. |
| Observability | B9.1c stores per-action `action_results`; B9.1d should extend that record rather than adding hidden logs only. | Action results currently record action name, success/failure, and error only. | Extend action result JSON with optional delivery fields such as `delivery_type`, `target`, `attempts`, `response_code`, `response_body`, and `retryable` while preserving existing tests. |
| Worker boundary | Chatwoot external delivery is asynchronous; Go can land a synchronous retry boundary first only if the durable queue gap is named. | Phase 5 durable worker parity is still planned. | Keep B9.1d scoped to retryable/testable delivery and record any durable queue gap under B9.3/Phase 5, not as hidden Done work. |
| Worker boundary | Chatwoot external delivery is asynchronous; Go can land a synchronous retry boundary first only if the durable queue gap is named. | Phase 5 durable worker parity is now in Review after B9.3/P5.1-P5.13. | Keep future async gaps evidence-backed under named drift rows, not hidden Done work. |
B9.1d implementation checklist:
@@ -713,7 +731,7 @@ B9.1d current checkpoint:
- `send_email_transcript` is no longer a no-op. It parses comma-separated recipients, builds a Chatwoot-style `[#display_id] Conversation Transcript` subject plus transcript body, and delivers per recipient through a fakeable SMTP transcript boundary.
- `ActionService.ExecuteWithResult` preserves the existing `Execute` API while returning per-action metadata for automation execution logs.
- `AutomationExecution.action_results` now includes optional `delivery_type`, `target`, `attempts`, `response_code`, `response_body`, and `retryable` fields. Failed external actions still allow later actions/rules to continue under the B9.1c behavior.
- B9.1 moves to `Review`. Durable queued scheduling and delayed action parity remain B9.3/Phase 5; the next active implementation slice is B9.2 macro CRUD and execution parity.
- B9.1 moves to `Review`. Historical next work was B9.2/B9.3; the active board above now records B9.2 and B9.3 in Review and keeps B9.4 as the remaining automation/macro depth row.
B9.1d verification:
@@ -1301,7 +1319,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 | 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. |
| B9 | Done: B9.1 automation rule CRUD/listener/log/external-action parity, B9.2 macro frontend CRUD/execute side effects, and B9.3 delayed-action reference closure plus team email/SLA action parity. | Deeper macro attachment/file parity remains B9.4; future delayed automation action work requires new reference or smoke evidence. | Review after `feat(automation): close delayed action parity`; move to Done only after B9.4 is implemented or formally split out. |
| B10 | Done: audit list payload, audit writer boundary for representative mutating core resources, CustomRole permission-key parity, AccountUser permission resolution, admin gates, delete nullification, and account-level InboxLimit enforcement in inbox/channel creation paths. | Frontend smoke coverage for enterprise settings remains B12, not a hidden B10 blocker. | Review after B10.4; move to Done only after reused frontend smoke confirms audit/custom-role/limit settings flows or any smoke gaps are split into owned follow-ups. |
| B11 | Done through B11.3e: Captain Assistant CRUD, inbox binding, scenarios, documents, responses, custom tools, Copilot threads/messages, Captain preferences, playground, document sync gates, task/tool-call payload persistence, stream disabled-state SSE, and Copilot push payload fixtures. | Reused frontend smoke remains B12; provider-specific durable realtime/LLM delivery can move to Phase 5 if smoke exposes it. | Review after B11.3e; move to Done only after B12 proves reused frontend Captain/Copilot screens or names any remaining provider/deployment follow-ups. |
| B12 | B12.1 boot/readiness, B12.2 core API/browser assertions, and B12.3 enterprise API/browser route assertions landed. | Optional live smoke runs in a full PostgreSQL/Redis/Meilisearch/Vite/Chrome environment must map every failure to an owning slice. | Move to Done only after live-smoke pass/fail results are recorded and every failure is owned. |
@@ -1583,7 +1601,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`, assignment-path enforcement, and account-level `usage_limits[:inboxes]` create-path enforcement are covered. | 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` | Captain resource payloads through B11.1c, Copilot thread/message payloads through B11.2, Captain preferences through B11.3a, Captain playground through B11.3b, document sync gates through B11.3c, task/tool-call payload persistence through B11.3d, stream/realtime fallback payloads through B11.3e, and durable P5.11 Captain/Copilot response jobs are aligned. | Review |
| 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, download CSV, and channel-specific template job hooks are aligned. | 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/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.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, delayed-action reference closure, team-email and SLA actions, and macro CRUD/display-ID execution parity are implemented; deeper macro attachment/file parity remains B9.4. | 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:
@@ -1593,7 +1611,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 | 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 |
| Automation rules | CRUD payload parity, condition/action parity, event listener coverage, delayed actions, execution logs, no-stop-on-match behavior, webhook, transcript, team-email, and SLA actions. | B9.1a CRUD, B9.1b listener coverage, B9.1c execution outcome logging, B9.1d retryable webhook/email transcript delivery, and B9.3 current-reference delayed-action closure plus team-email/SLA action coverage are covered. | Review |
| 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. | List/admin payload parity is covered by `feat(audit): align chatwoot audit log payloads`; named representative writer coverage is covered by `feat(audit): record enterprise mutations` and `feat(audit): cover operational mutations`. | Review |
| Custom roles | Permission-key parity, account-user role resolution, policy middleware, create/update/delete behavior. | Permission arrays, raw admin payloads, admin gates, RBAC/account-scope/profile resolution, and delete nullification are covered by `feat(custom-roles): align chatwoot permissions`. | Review |
@@ -1650,7 +1668,7 @@ Checklist:
- [x] Add `background_jobs` persistence with job type, payload, queue, status, attempt counters, scheduled/locked timestamps, idempotency key, last error, and completion timestamps.
- [x] Replace `internal/worker/worker.go` placeholder with enqueue, schedule, perform, retry/backoff, dead-letter, and graceful shutdown behavior.
- [x] Map the committed Chatwoot job/listener families to Go worker responsibilities in the tracking table.
- [ ] Finish durable job dispatch for delayed automation actions if current-reference params require it.
- [x] Finish durable job dispatch for delayed automation actions if current-reference params require it; current reference has no delayed action params, and remaining automation side effects are queued or explicitly covered.
- [ ] Add retry and failure logging for the remaining external provider calls.
- [ ] Add tests for the remaining enqueueing, uniqueness/idempotency, delayed execution, retries, worker restart pickup, and fakeable external side effects.
@@ -1667,7 +1685,7 @@ Tracking table:
| P5.2 | Route async dispatcher events through durable jobs. | `event_dispatcher_job.rb`, Chatwoot async dispatcher listeners | `internal/dispatch/dispatcher.go`, `internal/channel/dispatcher.go` | Heavy listeners can enqueue durable jobs without changing sync listener behavior; tests cover sync vs async routing and replay. | Review by `feat(dispatch): queue async events durably` |
| P5.3 | Move Meilisearch indexing and reindex fan-out into retryable jobs. | Meilisearch plan plus Chatwoot callbacks/jobs that index searchable records | search services, contact/company/conversation indexing hooks | Create/update/delete indexing survives handler success, retries on Meilisearch failure, and optional live Meilisearch gate remains green. | Review by `feat(search): queue index updates durably` |
| P5.4 | Queue automation webhook and transcript delivery. | `webhook_job.rb`, automation action execution services | `internal/automation/action_delivery.go`, `internal/automation/action_service.go` | Existing timeout/retry fakeable delivery is invoked by durable jobs; logs preserve attempt metadata and idempotency. | Review by `feat(automation): queue external action deliveries` |
| P5.5 | Queue delayed automation actions and macro execution. | `trigger_scheduled_items_job.rb`, `macros_execution_job.rb` | automation rule listener, macro service | Delayed actions execute after schedule time, macro execute supports multi-conversation job fan-out, and repeated workers do not duplicate side effects. | Doing: macro fan-out Review by `feat(automation): queue macro and csat jobs`; delayed actions Todo |
| P5.5 | Queue delayed automation actions and macro execution. | `trigger_scheduled_items_job.rb`, `macros_execution_job.rb` | automation rule listener, macro service | Delayed actions execute after schedule time if reference params exist; macro execute supports multi-conversation job fan-out, and repeated workers do not duplicate side effects. | Review by `feat(automation): queue macro and csat jobs` and `feat(automation): close delayed action parity`; current reference exposes no delayed automation action params |
| P5.6 | Queue CSAT survey sends and channel-specific templates. | CSAT listener/services, WhatsApp/Twilio template services/jobs | `internal/automation/csat_survey_listener.go`, `internal/service/csat_template_service.go`, channel send services | Resolve-triggered CSAT send is durable; WhatsApp/Twilio template delivery and failure states are fakeable and observable. | Review by `feat(automation): queue macro and csat jobs` and `feat(csat): queue channel templates` |
| P5.7 | Queue SLA account-wide scans and applied-SLA processing. | `enterprise/app/jobs/sla/trigger_slas_for_accounts_job.rb`, `process_account_applied_slas_job.rb`, `process_applied_sla_job.rb` | `internal/service/applied_sla_service.go`, SLA event services | Scheduler enqueues account scans and per-SLA processing; threshold transitions remain idempotent and notification fan-out is retryable. | Review by `feat(sla): queue applied sla scans` |
| P5.8 | Queue contact export artifact generation and completion notification/email. | `account/contacts_export_job.rb` | contact export service and mailer boundary | Export API returns immediately, artifact generation is durable, completion notification/email is fakeable, and download remains stable after restart. | Review by `feat(crm): queue contact exports` |
@@ -1751,7 +1769,8 @@ P5.5/P5.6 current checkpoint:
- `CsatTemplateService` now queues WhatsApp/Twilio template creation through `csat:template_create` jobs when a WorkerPool is configured. Worker replay calls a fakeable provider boundary, persists provider metadata into `inbox.csat_config`, and records provider failures in both template status and `background_jobs` retry state.
- Inbox CSAT template create/analyze handlers now accept Chatwoot nested `{ template: { message, button_text, language } }` payloads, and show returns the Chatwoot-style `template_exists` status object.
- Message-updated CSAT response building remains inline because it is part of the submitted widget/public API update path, not an external delivery side effect.
- Remaining P5.5/P5.6 work: delayed automation action semantics only if the current reference exposes explicit delayed action params; generic macro fan-out, CSAT survey send, and WhatsApp/Twilio template creation are now in Review.
- B9.3 reference audit confirms the current Chatwoot automation action shape has no explicit delayed action params. `send_email_to_team` now queues durable `automation:team_email_delivery` jobs and enterprise `add_sla` creates account-scoped conversation/applied-SLA state.
- Remaining P5.5/P5.6 work: none known for the named delayed automation/macro/CSAT job rows; future delayed action work requires new reference or smoke evidence.
P5.5/P5.6 verification:
@@ -1770,7 +1789,7 @@ P5.7 current checkpoint:
- `sla:process_account` mirrors `Sla::ProcessAccountAppliedSlasJob`: it finds active and active_with_misses AppliedSLA rows for the account and queues `sla:process_applied` jobs.
- `sla:process_applied` mirrors `Sla::ProcessAppliedSlaJob`: it calls the existing `AppliedSlaService.Evaluate`, preserving idempotent miss events, notification fan-out, retry/backoff, and dead-letter visibility through `background_jobs`.
- Bootstrap registers the SLA job handlers and enqueues the initial root scan when the app boots.
- Remaining Phase 5 work: delayed automation scheduled-item execution if reference params require it.
- Remaining Phase 5 work: none known for the named SLA job row; future scheduling gaps require new reference or smoke evidence.
P5.7 verification:
@@ -1861,7 +1880,7 @@ P5.11 current checkpoint:
- Conversation response replay collects public incoming/outgoing history, maps incoming to user and outgoing to assistant context, creates Captain outgoing messages with optional `agent_name`, enqueues `message:send_reply`, and handles handoff responses by creating the configured handoff message and opening the pending conversation.
- No-worker construction keeps the previous mark-syncing fallback for focused tests and local paths that do not start the durable worker.
- Bootstrap registers Captain document, Copilot response, and Captain conversation response jobs on the shared WorkerPool and wires the assistant-response repository so production sync/crawl/schedule/response/embedding/Copilot/conversation requests are replayable after process restart.
- Remaining P5.11 work: none known for the named durable Captain/Copilot job row; analytics aggregation is now in P5.13 Review, so the next Phase 5 check is B9.3 delayed automation actions.
- Remaining P5.11 work: none known for the named durable Captain/Copilot job row; analytics aggregation and delayed automation action checks are now in Review.
P5.11 verification:
@@ -2029,7 +2048,8 @@ Verification milestone gates:
## Progress Log
- 2026-06-05: P5.13b scheduled/cached analytics checkpoint prepared as `feat(reports): add analytics timeseries rollups`; `GET /api/v1/accounts/:account_id/reports` and v2 `/reports` now return Chatwoot-style metric timeseries with account/inbox/agent/team/label dimensions, day/hour/week/month/year buckets, and business-hours average support. Analytics reads lazily ensure missing daily rollups, `reporting:rollup_day` durable jobs replay account/date recomputes with idempotency keys, and rollup replacement hard-deletes soft-deleted rows before refresh. Route dump is now `TOTAL: 833`; tracked route parity remains `270 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 277 tracked critical routes`. Focused analytics service tests, analytics/live handler tests, service/handler/router/worker/app package tests, route generation/parity, full `go test ./...`, and `git diff --check` passed. P5.13 moves to Review; next slice is B9.3 delayed automation action verification.
- 2026-06-05: B9.3 delayed automation action checkpoint prepared as `feat(automation): close delayed action parity`; audited current `reference/chatwoot` automation rule model, listener, action services, enterprise action service, and dashboard action inputs and found no configurable delayed action params. Scheduled root/background work remains covered by P5.12, while this checkpoint closes remaining automation action side effects: `send_email_to_team` now preserves Chatwoot object-shaped params and queues durable `automation:team_email_delivery` jobs through the fakeable mailer boundary, and enterprise `add_sla` attaches account-scoped SLA policies plus idempotent AppliedSLA target rows. Focused automation action/execution tests, AutomationRule handler tests, automation/handler/worker/app package tests, full `go test ./...`, and `git diff --check` passed. B9.3 and Phase 5 move to Review; next slice is Phase 2/3 drift audit.
- 2026-06-05: P5.13b scheduled/cached analytics checkpoint prepared as `feat(reports): add analytics timeseries rollups`; `GET /api/v1/accounts/:account_id/reports` and v2 `/reports` now return Chatwoot-style metric timeseries with account/inbox/agent/team/label dimensions, day/hour/week/month/year buckets, and business-hours average support. Analytics reads lazily ensure missing daily rollups, `reporting:rollup_day` durable jobs replay account/date recomputes with idempotency keys, and rollup replacement hard-deletes soft-deleted rows before refresh. Route dump is now `TOTAL: 833`; tracked route parity remains `270 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 277 tracked critical routes`. Focused analytics service tests, analytics/live handler tests, service/handler/router/worker/app package tests, route generation/parity, full `go test ./...`, and `git diff --check` passed. P5.13 moves to Review; B9.3 is completed by `feat(automation): close delayed action parity`.
- 2026-06-05: P5.13a analytics placeholder burn-down checkpoint prepared as `feat(reports): derive analytics aggregates`; live report conversation metrics and grouped metrics now read persisted conversations, bot summary/metrics read reporting events plus active agent-bot inbox bindings, conversation summary reads conversations/messages/reporting events, inbox-label matrix reads inbox/tag/conversation-label rows, first-response distribution buckets reporting events by channel, and outgoing-message counts group by agent/team/inbox/label. Focused analytics service tests, analytics/live handler tests, service/handler/worker/app package tests, full `go test ./...`, and `git diff --check` passed. Remaining P5.13 follow-up is scheduled/cached rollup freshness and `/reports` timeseries index parity.
- 2026-06-05: P5.11c Copilot/conversation response checkpoint prepared as `feat(captain): queue copilot response jobs`; Copilot thread/message creation now enqueues `captain:copilot_response` after persisting the user message, worker replay persists assistant replies through a fakeable backend or the existing no-provider fallback, and backend failures surface as retryable jobs. Pending Captain-enabled incoming conversations now enqueue `captain:conversation_response_builder`, replay creates Captain outgoing replies, queues provider send-reply, and opens conversations on handoff. Focused Copilot/Captain conversation worker tests, service/worker/app package tests, full `go test ./...`, and `git diff --check` passed. P5.11 moves to Review; next slice is P5.13 analytics aggregation.
- 2026-06-05: P5.11b Captain response/embedding checkpoint prepared as `feat(captain): queue response embedding jobs`; successful document sync/parser content updates now enqueue `captain:document_response_builder`, response-builder replay resets only unedited document responses, preserves edited responses, creates approved `Captain::Document` assistant responses through a fakeable FAQ backend, and fans out `captain:llm_update_embedding` jobs. Embedding replay reloads account-scoped responses and uses a fakeable embedding backend or configured LLM provider, with missing providers surfacing as retryable worker failures. Focused Captain document worker tests, service/worker/app package tests, full `go test ./...`, and `git diff --check` passed. Remaining P5.11 follow-up is Copilot/conversation response jobs.
+1
View File
@@ -21,6 +21,7 @@ const (
defaultActionDeliveryTimeout = 10 * time.Second
TaskTypeAutomationWebhookDelivery = "automation:webhook_delivery"
TaskTypeAutomationTranscriptDelivery = "automation:transcript_delivery"
TaskTypeAutomationTeamEmailDelivery = "automation:team_email_delivery"
)
// ActionDeliveryResult is copied into AutomationExecution.action_results so
@@ -24,6 +24,13 @@ type automationTranscriptDeliveryJob struct {
Recipient string `json:"recipient"`
}
type automationTeamEmailDeliveryJob struct {
AccountID uint `json:"account_id"`
ConversationID uint `json:"conversation_id"`
TeamID uint `json:"team_id"`
Message string `json:"message"`
}
var actionDeliveryRegistrations sync.Map
// RegisterActionDeliveryJobs wires Chatwoot-style automation external side
@@ -43,6 +50,7 @@ func RegisterActionDeliveryJobs(wp *worker.WorkerPool, db DBProvider) {
}
wp.Register(TaskTypeAutomationWebhookDelivery, runner.performWebhookDelivery)
wp.Register(TaskTypeAutomationTranscriptDelivery, runner.performTranscriptDelivery)
wp.Register(TaskTypeAutomationTeamEmailDelivery, runner.performTeamEmailDelivery)
}
type actionDeliveryJobRunner struct {
@@ -84,3 +92,20 @@ func (r *actionDeliveryJobRunner) performTranscriptDelivery(ctx context.Context,
})
return err
}
func (r *actionDeliveryJobRunner) performTeamEmailDelivery(ctx context.Context, job *model.BackgroundJob) error {
var payload automationTeamEmailDeliveryJob
if err := json.Unmarshal(job.Payload, &payload); err != nil {
return fmt.Errorf("unmarshal automation team email job: %w", err)
}
requests, err := NewActionService(r.db).buildTeamEmailRequests(ctx, payload.AccountID, payload.ConversationID, []uint{payload.TeamID}, payload.Message)
if err != nil {
return err
}
for _, req := range requests {
if _, err := r.transcriptDeliverer.DeliverTranscript(ctx, req); err != nil {
return err
}
}
return nil
}
+243
View File
@@ -4,12 +4,14 @@ import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/worker"
applogger "github.com/gochat/gochat/pkg/logger"
"gorm.io/gorm"
)
// ActionSource identifies who triggered the action execution.
@@ -101,6 +103,8 @@ func (s *ActionService) ExecuteWithResult(ctx context.Context, accountID uint, c
switch resolvedAction.ActionName {
case "send_message":
err = s.handleSendMessage(ctx, accountID, conversationID, resolvedAction, source, sourceID)
case "send_email_to_team":
deliveryResult, err = s.handleSendEmailToTeam(ctx, accountID, conversationID, resolvedAction)
case "add_label":
err = s.handleAddLabel(ctx, accountID, conversationID, resolvedAction)
case "remove_label":
@@ -141,6 +145,8 @@ func (s *ActionService) ExecuteWithResult(ctx context.Context, accountID uint, c
})
case "change_priority":
err = s.handleChangePriority(ctx, accountID, conversationID, resolvedAction)
case "add_sla":
err = s.handleAddSla(ctx, accountID, conversationID, resolvedAction)
case "send_email_transcript":
deliveryResult, err = s.handleSendEmailTranscript(ctx, accountID, conversationID, resolvedAction)
case "send_attachment":
@@ -210,6 +216,49 @@ func (s *ActionService) handleSendMessage(ctx context.Context, accountID, conver
return s.db.DB().WithContext(ctx).Create(msg).Error
}
// handleSendEmailToTeam sends Chatwoot-style automation team notifications.
// Reference: AutomationRules::ActionService#send_email_to_team receives
// { team_ids, message } and sends one email to each member of each team.
func (s *ActionService) handleSendEmailToTeam(ctx context.Context, accountID, conversationID uint, action Action) (ActionDeliveryResult, error) {
teamIDs := extractUintSlice(action.ActionParams, "team_ids", "team_id")
message := firstStringParam(action.ActionParams, "message", "content")
if len(teamIDs) == 0 {
return ActionDeliveryResult{DeliveryType: "team_email"}, fmt.Errorf("send_email_to_team action requires 'team_ids' param")
}
if s.worker != nil {
for _, teamID := range teamIDs {
_, err := s.worker.Enqueue(ctx, TaskTypeAutomationTeamEmailDelivery, automationTeamEmailDeliveryJob{
AccountID: accountID,
ConversationID: conversationID,
TeamID: teamID,
Message: message,
}, worker.WithQueue("automation"), worker.WithMaxAttempts(defaultActionDeliveryAttempts))
if err != nil {
return ActionDeliveryResult{DeliveryType: "team_email", Target: joinUintTargets(teamIDs)}, err
}
}
return ActionDeliveryResult{DeliveryType: "team_email", Target: joinUintTargets(teamIDs), ResponseBody: "queued", Queued: true}, nil
}
requests, err := s.buildTeamEmailRequests(ctx, accountID, conversationID, teamIDs, message)
if err != nil {
return ActionDeliveryResult{DeliveryType: "team_email", Target: joinUintTargets(teamIDs)}, err
}
aggregate := ActionDeliveryResult{DeliveryType: "team_email", Target: joinUintTargets(teamIDs)}
for _, req := range requests {
result, err := s.transcriptDeliverer.DeliverTranscript(ctx, req)
aggregate.Attempts += result.Attempts
aggregate.ResponseCode = result.ResponseCode
aggregate.ResponseBody = result.ResponseBody
aggregate.Retryable = result.Retryable
if err != nil {
return aggregate, err
}
}
return aggregate, nil
}
// handleAddLabel adds a label to the conversation.
// Reference: Chatwoot add_label action — adds tag/label to conversation
func (s *ActionService) handleAddLabel(ctx context.Context, accountID, conversationID uint, action Action) error {
@@ -409,6 +458,52 @@ func (s *ActionService) handleChangePriority(ctx context.Context, accountID, con
Update("priority", priority).Error
}
// handleAddSla attaches an account-scoped SLA policy when the conversation
// does not already have one. Reference: Enterprise::ActionService#add_sla.
func (s *ActionService) handleAddSla(ctx context.Context, accountID, conversationID uint, action Action) error {
slaPolicyID := extractUintParam(action.ActionParams, "sla_policy_id")
if slaPolicyID == 0 {
return fmt.Errorf("add_sla action requires 'sla_policy_id' param")
}
var policy model.SlaPolicy
if err := s.db.DB().WithContext(ctx).Where("id = ? AND account_id = ?", slaPolicyID, accountID).First(&policy).Error; err != nil {
return fmt.Errorf("sla policy not found: %w", err)
}
var conversation model.Conversation
if err := s.db.DB().WithContext(ctx).Where("id = ? AND account_id = ?", conversationID, accountID).First(&conversation).Error; err != nil {
return fmt.Errorf("conversation not found: %w", err)
}
if conversation.SlaPolicyID != nil && *conversation.SlaPolicyID != 0 {
return nil
}
return s.db.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.Conversation{}).
Where("id = ? AND account_id = ? AND (sla_policy_id IS NULL OR sla_policy_id = 0)", conversationID, accountID).
Update("sla_policy_id", slaPolicyID)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return nil
}
if err := tx.Where("id = ? AND account_id = ?", conversationID, accountID).First(&conversation).Error; err != nil {
return err
}
var count int64
if err := tx.Model(&model.AppliedSLA{}).Where("account_id = ? AND conversation_id = ?", accountID, conversationID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
applied := appliedSlaFromAutomationPolicy(accountID, conversationID, conversation, policy)
return tx.Create(applied).Error
})
}
// handleSendEmailTranscript sends an email transcript of the conversation.
// Reference: Chatwoot send_email_transcript action splits comma-separated emails.
func (s *ActionService) handleSendEmailTranscript(ctx context.Context, accountID, conversationID uint, action Action) (ActionDeliveryResult, error) {
@@ -576,6 +671,73 @@ func extractUintParam(params map[string]interface{}, key string) uint {
}
}
func extractUintSlice(params map[string]interface{}, keys ...string) []uint {
for _, key := range keys {
raw, ok := params[key]
if !ok || raw == nil {
continue
}
switch v := raw.(type) {
case []uint:
return v
case []int:
out := make([]uint, 0, len(v))
for _, item := range v {
if item > 0 {
out = append(out, uint(item))
}
}
return out
case []interface{}:
out := make([]uint, 0, len(v))
for _, item := range v {
if parsed := uintFromAny(item); parsed > 0 {
out = append(out, parsed)
}
}
return out
default:
if parsed := uintFromAny(v); parsed > 0 {
return []uint{parsed}
}
}
}
return nil
}
func uintFromAny(raw interface{}) uint {
switch v := raw.(type) {
case uint:
return v
case int:
if v > 0 {
return uint(v)
}
case int64:
if v > 0 {
return uint(v)
}
case float64:
if v > 0 {
return uint(v)
}
case string:
parsed, err := strconv.ParseUint(strings.TrimSpace(v), 10, 64)
if err == nil && parsed > 0 {
return uint(parsed)
}
}
return 0
}
func joinUintTargets(values []uint) string {
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, fmt.Sprintf("%d", value))
}
return strings.Join(parts, ",")
}
func applyDeliveryResult(result *ActionExecutionResult, delivery ActionDeliveryResult) {
if delivery.DeliveryType == "" && delivery.Target == "" && delivery.Attempts == 0 && delivery.ResponseCode == 0 && delivery.ResponseBody == "" && !delivery.Retryable {
return
@@ -748,6 +910,87 @@ func (s *ActionService) buildTranscriptEmail(ctx context.Context, accountID, con
return subject, body.String(), nil
}
func (s *ActionService) buildTeamEmailRequests(ctx context.Context, accountID, conversationID uint, teamIDs []uint, message string) ([]AutomationTranscriptRequest, error) {
var conversation model.Conversation
if err := s.db.DB().WithContext(ctx).
Where("id = ? AND account_id = ?", conversationID, accountID).
First(&conversation).Error; err != nil {
return nil, err
}
displayID := conversation.ID
if conversation.DisplayID != nil && *conversation.DisplayID > 0 {
displayID = *conversation.DisplayID
}
subject := fmt.Sprintf("Conversation (#%d) automation notification", displayID)
body := strings.TrimSpace(message)
if body == "" {
body = fmt.Sprintf("Conversation #%d matched an automation rule.", displayID)
}
requests := make([]AutomationTranscriptRequest, 0)
seen := map[string]bool{}
for _, teamID := range teamIDs {
var team model.Team
if err := s.db.DB().WithContext(ctx).Where("id = ? AND account_id = ?", teamID, accountID).First(&team).Error; err != nil {
return nil, err
}
var users []model.User
if err := s.db.DB().WithContext(ctx).
Joins("INNER JOIN team_members ON team_members.user_id = users.id").
Joins("INNER JOIN account_users ON account_users.user_id = users.id AND account_users.account_id = ?", accountID).
Where("team_members.team_id = ? AND users.email <> ''", teamID).
Order("users.id ASC").
Find(&users).Error; err != nil {
return nil, err
}
for _, user := range users {
recipient := strings.TrimSpace(user.Email)
if recipient == "" || seen[recipient] {
continue
}
seen[recipient] = true
requests = append(requests, AutomationTranscriptRequest{
AccountID: accountID,
ConversationID: conversationID,
Recipient: recipient,
Subject: subject,
Body: body,
})
}
}
return requests, nil
}
func appliedSlaFromAutomationPolicy(accountID, conversationID uint, conversation model.Conversation, policy model.SlaPolicy) *model.AppliedSLA {
applied := &model.AppliedSLA{AccountID: accountID, ConversationID: conversationID, SlaPolicyID: policy.ID, SLAStatus: model.SLAStatusActive}
if policy.FirstResponseTimeThreshold > 0 {
frt := conversation.CreatedAt.Add(time.Duration(policy.FirstResponseTimeThreshold) * time.Second)
applied.FRTTargetAt = &frt
}
if policy.NextResponseTimeThreshold > 0 {
if applied.FRTTargetAt != nil {
nrt := applied.FRTTargetAt.Add(time.Duration(policy.NextResponseTimeThreshold) * time.Second)
applied.NRTTargetAt = &nrt
} else {
nrt := conversation.CreatedAt.Add(time.Duration(policy.NextResponseTimeThreshold) * time.Second)
applied.NRTTargetAt = &nrt
}
}
if policy.ResolutionTimeThreshold > 0 {
rt := conversation.CreatedAt.Add(time.Duration(policy.ResolutionTimeThreshold) * time.Second)
applied.RTTargetAt = &rt
}
if conversation.FirstReplyCreatedAt != nil {
frtActual := time.Unix(*conversation.FirstReplyCreatedAt, 0)
applied.FRTActualAt = &frtActual
}
if conversation.ResolvedAt != nil {
applied.RTActualAt = conversation.ResolvedAt
}
return applied
}
func jsonObject(raw []byte) map[string]interface{} {
if len(raw) == 0 {
return map[string]interface{}{}
+106
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
@@ -237,6 +238,111 @@ func TestActionService_SendEmailTranscript_QueuesDurableDeliveries(t *testing.T)
}
}
func TestActionService_SendEmailToTeam_QueuesDurableTeamNotifications(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
db := dbProvider.DB()
accountID, _ := seedTestAccount(db, t)
inboxID := seedTestInbox(db, t, accountID)
contactID := seedTestContact(db, t, accountID)
conversationID := seedTestConversation(db, t, accountID, inboxID, contactID)
team := &model.Team{AccountID: accountID, Name: "Escalations"}
if err := db.Create(team).Error; err != nil {
t.Fatalf("seed team: %v", err)
}
user := &model.User{AccountID: accountID, Name: "Team Agent", Email: "team-agent@example.com"}
if err := db.Create(user).Error; err != nil {
t.Fatalf("seed user: %v", err)
}
if err := db.Create(&model.AccountUser{AccountID: accountID, UserID: user.ID, Role: "agent"}).Error; err != nil {
t.Fatalf("seed account user: %v", err)
}
if err := db.Create(&model.TeamMember{TeamID: team.ID, UserID: user.ID}).Error; err != nil {
t.Fatalf("seed team member: %v", err)
}
transcript := &recordingTranscriptDeliverer{result: ActionDeliveryResult{DeliveryType: "team_email", Attempts: 1}}
restore := setAutomationActionDeliverersForTest(&recordingWebhookDeliverer{}, transcript)
defer restore()
wp := worker.NewWorkerPool(db)
result, err := NewActionServiceWithWorker(dbProvider, wp).ExecuteWithResult(context.Background(), accountID, conversationID, Action{
ActionName: "send_email_to_team",
ActionParams: map[string]interface{}{
"team_ids": []interface{}{float64(team.ID)},
"message": "Please check this conversation",
},
}, ActionSourceAutomation, 99)
if err != nil {
t.Fatalf("queue team email action: %v", err)
}
if !result.Queued || result.DeliveryType != "team_email" || result.Target != fmt.Sprintf("%d", team.ID) {
t.Fatalf("unexpected queued team email result: %#v", result)
}
if len(transcript.requests) != 0 {
t.Fatalf("team email should not deliver synchronously, got %d", len(transcript.requests))
}
processed, err := wp.ProcessOne(context.Background())
if err != nil || !processed {
t.Fatalf("process team email job: processed=%v err=%v", processed, err)
}
if len(transcript.requests) != 1 || transcript.requests[0].Recipient != "team-agent@example.com" {
t.Fatalf("unexpected durable team email requests: %#v", transcript.requests)
}
if !strings.Contains(transcript.requests[0].Body, "Please check this conversation") {
t.Fatalf("expected team email message body, got %#v", transcript.requests[0])
}
}
func TestActionService_AddSla_AttachesPolicyAndAppliedSLA(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
db := dbProvider.DB()
accountID, _ := seedTestAccount(db, t)
inboxID := seedTestInbox(db, t, accountID)
contactID := seedTestContact(db, t, accountID)
conversationID := seedTestConversation(db, t, accountID, inboxID, contactID)
policy := &model.SlaPolicy{AccountID: accountID, Name: "Gold", FirstResponseTimeThreshold: 60, ResolutionTimeThreshold: 3600}
if err := db.Create(policy).Error; err != nil {
t.Fatalf("seed sla policy: %v", err)
}
_, err := NewActionService(dbProvider).ExecuteWithResult(context.Background(), accountID, conversationID, Action{
ActionName: "add_sla",
ActionParams: map[string]interface{}{"sla_policy_id": float64(policy.ID)},
}, ActionSourceAutomation, 99)
if err != nil {
t.Fatalf("add sla action: %v", err)
}
var conversation model.Conversation
if err := db.First(&conversation, conversationID).Error; err != nil {
t.Fatalf("reload conversation: %v", err)
}
if conversation.SlaPolicyID == nil || *conversation.SlaPolicyID != policy.ID {
t.Fatalf("expected conversation sla_policy_id %d, got %#v", policy.ID, conversation.SlaPolicyID)
}
var applied model.AppliedSLA
if err := db.Where("conversation_id = ?", conversationID).First(&applied).Error; err != nil {
t.Fatalf("expected applied sla: %v", err)
}
if applied.SlaPolicyID != policy.ID || applied.FRTTargetAt == nil || applied.RTTargetAt == nil {
t.Fatalf("unexpected applied sla: %#v", applied)
}
_, err = NewActionService(dbProvider).ExecuteWithResult(context.Background(), accountID, conversationID, Action{
ActionName: "add_sla",
ActionParams: map[string]interface{}{"sla_policy_id": float64(policy.ID)},
}, ActionSourceAutomation, 99)
if err != nil {
t.Fatalf("repeat add sla action should be idempotent: %v", err)
}
var count int64
if err := db.Model(&model.AppliedSLA{}).Where("conversation_id = ?", conversationID).Count(&count).Error; err != nil {
t.Fatalf("count applied slas: %v", err)
}
if count != 1 {
t.Fatalf("expected one applied sla after repeat, got %d", count)
}
}
func TestAutomationRuleService_MatchAndExecute_RecordsEmailTranscriptFailureMetadata(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
db := dbProvider.DB()
@@ -41,6 +41,8 @@ func setupAutomationTestDB(t *testing.T) *gorm.DB {
&model.Message{},
&model.Team{},
&model.TeamMember{},
&model.SlaPolicy{},
&model.AppliedSLA{},
&AutomationRule{},
&Macro{},
&MacroExecution{},
@@ -426,8 +426,10 @@ func actionArrayToMap(actionName string, values []interface{}) map[string]interf
switch actionName {
case "assign_agent":
params["assignee_id"] = first
case "assign_team", "send_email_to_team":
case "assign_team":
params["team_id"] = first
case "send_email_to_team":
params["team_ids"] = values
case "add_label", "remove_label":
params["labels"] = valuesToStrings(values)
case "change_status":
@@ -450,7 +452,7 @@ func actionArrayToMap(actionName string, values []interface{}) map[string]interf
return params
}
func chatwootActionParams(action automation.Action) []interface{} {
func chatwootActionParams(action automation.Action) interface{} {
params := action.ActionParams
if params == nil {
return []interface{}{}
@@ -458,8 +460,13 @@ func chatwootActionParams(action automation.Action) []interface{} {
switch action.ActionName {
case "assign_agent":
return compactValues(params["assignee_id"], params["agent_id"])
case "assign_team", "send_email_to_team":
case "assign_team":
return compactValues(params["team_id"])
case "send_email_to_team":
return gin.H{
"team_ids": valuesToInterfaces(firstNonNil(params["team_ids"], params["team_id"])),
"message": firstNonNil(params["message"], params["content"]),
}
case "add_label", "remove_label":
if labels, ok := params["labels"]; ok {
return valuesToInterfaces(labels)
@@ -487,6 +494,15 @@ func chatwootActionParams(action automation.Action) []interface{} {
}
}
func firstNonNil(values ...interface{}) interface{} {
for _, value := range values {
if value != nil {
return value
}
}
return nil
}
func firstValue(values []interface{}) interface{} {
if len(values) == 0 {
return nil
@@ -513,6 +529,18 @@ func valuesToInterfaces(value interface{}) []interface{} {
result = append(result, item)
}
return result
case []uint:
result := make([]interface{}, 0, len(typed))
for _, item := range typed {
result = append(result, item)
}
return result
case []int:
result := make([]interface{}, 0, len(typed))
for _, item := range typed {
result = append(result, item)
}
return result
case nil:
return []interface{}{}
default: