feat(inboxes): align whatsapp health endpoints

This commit is contained in:
2026-06-05 22:25:04 +08:00
parent 3fc275b639
commit af9e1eb711
6 changed files with 276 additions and 272 deletions
+7 -5
View File
@@ -16,10 +16,10 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc
## Current Baseline ## Current Baseline
- Current tracking checkpoint: 2026-06-05 after `ddf2694 feat(automation): close delayed action parity`, with this implementation checkpoint prepared as `feat(notifications): align chatwoot notification payloads`. - Current tracking checkpoint: 2026-06-05 after `3fc275b feat(notifications): align chatwoot notification payloads`, with this implementation checkpoint prepared as `feat(inboxes): align whatsapp health endpoints`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(notifications): align chatwoot notification payloads`. - Latest implementation checkpoint: this checkpoint, prepared as `feat(inboxes): align whatsapp health endpoints`.
- Latest documentation-only checkpoint: `2923aae docs: land parity execution tracker`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`. - 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; 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`; Phase 2/3 drift audit now aligns account notifications with Chatwoot `NotificationFinder` list envelopes, `includes` filters, unread counts, primary-actor `read_all`, raw action responses, and `destroy_all` read/all semantics. Next active implementation slice is Phase 6 placeholder audit, followed by further Phase 2/3 drift from smoke/reference evidence. - 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`; Phase 2/3 drift audit now aligns account notifications with Chatwoot `NotificationFinder` list envelopes, `includes` filters, unread counts, primary-actor `read_all`, raw action responses, and `destroy_all` read/all semantics; P6.5 now aligns WhatsApp Cloud inbox health/register-webhook endpoints with Chatwoot raw payloads, Cloud-only guards, Graph health fields, default callback URL construction, and no-body register behavior. Next active implementation slice is continued Phase 6 placeholder audit, followed by further Phase 2/3 drift from smoke/reference evidence.
- `go test ./...` passes. - `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 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`. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
@@ -88,7 +88,7 @@ This table is the shortest authoritative handoff view. If an older lower section
| Priority | Workstream | Current state | Next checkpoint | Commit close rule | | Priority | Workstream | Current state | Next checkpoint | Commit close rule |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current critical set; notification list/action serializers now match Chatwoot's frontend contract; broader serializer fixtures remain partial. | Expand route/serializer fixtures when smoke or reference inspection exposes drift. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. | | 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current critical set; notification list/action serializers now match Chatwoot's frontend contract; broader serializer fixtures remain partial. | Expand route/serializer fixtures when smoke or reference inspection exposes drift. | Regenerate parity artifacts when routes change 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. | | 2 | Phase 6 placeholder audit | Widget/public/webhook critical placeholders are burned down; inbox WhatsApp health/register-webhook drift is closed; account/contact/conversation/message/inbox audit remains broad. | Continue fresh placeholder audit and assign every frontend-reachable stub to a tracked owner. | `rg` placeholder audit 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. | | 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. | | 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. | | 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. |
@@ -139,6 +139,7 @@ This ledger records the committed parity checkpoints that future slices should b
| Commit | Scope | Verification summary | Follow-up state | | Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `feat(inboxes): align whatsapp health endpoints` | Advances P6.5 inbox API parity by replacing the generic inbox health result with Chatwoot's WhatsApp Cloud-only `health` contract and aligning `register_webhook` with the reference concern. Health now returns raw Graph phone-number fields plus `expected_webhook_url` and `business_id`; non-WhatsApp/non-Cloud inboxes return the Chatwoot `400` error; register webhook accepts an empty body, builds the default callback from `FRONTEND_URL`, and returns `Webhook registered successfully`. WhatsApp Graph health reads now use `WHATSAPP_API_VERSION` default `v22.0`. | `go test ./internal/channel/whatsapp -count=1`; `go test ./internal/service -run InboxService -count=1`; `go test ./internal/handler/api/v1 -run Inbox -count=1`; `go test ./...`; `git diff --check`. | P6.5 remains Doing for broader inbox/channel settings parity; continue Phase 6 placeholder audit across account/contact/conversation/message/inbox surfaces. |
| `feat(notifications): align chatwoot notification payloads` | Advances Phase 2/3 drift by aligning the account notifications API with `reference/chatwoot/app/controllers/api/v1/accounts/notifications_controller.rb`, `NotificationFinder`, `index.json.jbuilder`, and the reused frontend `notifications.js` client. Notification index now returns Chatwoot `{ data: { meta, payload } }`, supports `includes[]=read/snoozed`, fixed unread/count metadata, raw notification action responses, primary-actor-scoped `read_all`, raw unread count, and `destroy_all` read/all semantics from query or JSON body. | `go test ./internal/handler/api/v1 -run Notification -count=1`; `go test ./internal/service -run Notification -count=1`; `go test ./internal/handler/api/v1 ./internal/service -count=1`; `go test ./...`; `git diff --check`. | Notification serializer/action drift moves to Review; continue Phase 6 placeholder audit, then further Phase 2/3 drift from smoke/reference evidence. | | `feat(notifications): align chatwoot notification payloads` | Advances Phase 2/3 drift by aligning the account notifications API with `reference/chatwoot/app/controllers/api/v1/accounts/notifications_controller.rb`, `NotificationFinder`, `index.json.jbuilder`, and the reused frontend `notifications.js` client. Notification index now returns Chatwoot `{ data: { meta, payload } }`, supports `includes[]=read/snoozed`, fixed unread/count metadata, raw notification action responses, primary-actor-scoped `read_all`, raw unread count, and `destroy_all` read/all semantics from query or JSON body. | `go test ./internal/handler/api/v1 -run Notification -count=1`; `go test ./internal/service -run Notification -count=1`; `go test ./internal/handler/api/v1 ./internal/service -count=1`; `go test ./...`; `git diff --check`. | Notification serializer/action drift moves to Review; continue Phase 6 placeholder audit, then further Phase 2/3 drift from smoke/reference evidence. |
| `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(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): 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. |
@@ -1962,7 +1963,7 @@ Tracking table:
| P6.2 | Contact APIs | `docs/ROUTE_GAP_ANALYSIS.md`, contact handlers/services | Finish merge, import/export/data-import, notes serializer depth, and Meilisearch-backed CRM search. | Doing | | P6.2 | Contact APIs | `docs/ROUTE_GAP_ANALYSIS.md`, contact handlers/services | Finish merge, import/export/data-import, notes serializer depth, and Meilisearch-backed CRM search. | Doing |
| P6.3 | Conversation APIs | `docs/ROUTE_GAP_ANALYSIS.md`, conversation handlers/services | Implement frontend-critical filters, assignment, status, snooze, merge, bulk actions. | Todo | | P6.3 | Conversation APIs | `docs/ROUTE_GAP_ANALYSIS.md`, conversation handlers/services | Implement frontend-critical filters, assignment, status, snooze, merge, bulk actions. | Todo |
| P6.4 | Message APIs | `docs/ROUTE_GAP_ANALYSIS.md`, message handlers/services | Implement create/list/delete, private notes, attachments, source attribution, events. | Todo | | P6.4 | Message APIs | `docs/ROUTE_GAP_ANALYSIS.md`, message handlers/services | Implement create/list/delete, private notes, attachments, source attribution, events. | Todo |
| P6.5 | Inbox APIs | `docs/ROUTE_GAP_ANALYSIS.md`, inbox handlers/services | Implement CRUD, assignable agents, avatar, campaigns, channel settings, reset secret. | Doing | | P6.5 | Inbox APIs | `docs/ROUTE_GAP_ANALYSIS.md`, inbox handlers/services | Implement CRUD, assignable agents, avatar, campaigns, channel settings, reset secret. WhatsApp Cloud health/register-webhook member actions now match the reference concern. | Doing |
| P6.6 | Widget/public APIs | `docs/ROUTE_GAP_ANALYSIS.md`, widget/channel provider code, `chatwootParityStub` routes | Widget/public frontend-critical route behavior is handler-backed, including public inbox flow, direct uploads/attachments, and public CSAT survey submission. | Done | | P6.6 | Widget/public APIs | `docs/ROUTE_GAP_ANALYSIS.md`, widget/channel provider code, `chatwootParityStub` routes | Widget/public frontend-critical route behavior is handler-backed, including public inbox flow, direct uploads/attachments, and public CSAT survey submission. | Done |
| P6.7 | Webhook ingress | `internal/router/router.go`, `internal/handler/webhook/*`, channel providers | Replace generic placeholder with provider-specific verified ingestion and dispatch. | Done | | P6.7 | Webhook ingress | `internal/router/router.go`, `internal/handler/webhook/*`, channel providers | Replace generic placeholder with provider-specific verified ingestion and dispatch. | Done |
@@ -2049,6 +2050,7 @@ Verification milestone gates:
## Progress Log ## Progress Log
- 2026-06-05: P6.5 inbox placeholder/drift checkpoint prepared as `feat(inboxes): align whatsapp health endpoints`; audited `reference/chatwoot/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb`, `Whatsapp::HealthService`, `Whatsapp::WebhookSetupService`, and the reused frontend inbox health client/component. `GET /inboxes/:id/health` now returns the raw WhatsApp Cloud Graph health payload with `expected_webhook_url` and `business_id`, rejects non-WhatsApp/non-Cloud inboxes with Chatwoot's `400` error, and drops the generic `healthy/status/details` placeholder shape. `POST /inboxes/:id/register_webhook` now accepts an empty body, builds the default callback from `FRONTEND_URL`, returns `Webhook registered successfully`, and uses the same Cloud-only guard. Focused WhatsApp, inbox service, inbox handler tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder audit next.
- 2026-06-05: Phase 2/3 notification drift checkpoint prepared as `feat(notifications): align chatwoot notification payloads`; audited `reference/chatwoot` notifications controller, `NotificationFinder`, index Jbuilder, and dashboard notifications API/store consumers. Account notifications index now returns Chatwoot `{ data: { meta, payload } }`, filters read/snoozed items with `includes[]=read/snoozed`, reports `count/current_page/unread_count`, serializes frontend-required notification fields, marks all notifications or a primary actor as read, returns raw update/unread/snooze notification payloads, returns unread count as a raw number, and honors `destroy_all` `type=read` from query or JSON body. Focused notification handler/service tests, combined handler/service tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder audit next. - 2026-06-05: Phase 2/3 notification drift checkpoint prepared as `feat(notifications): align chatwoot notification payloads`; audited `reference/chatwoot` notifications controller, `NotificationFinder`, index Jbuilder, and dashboard notifications API/store consumers. Account notifications index now returns Chatwoot `{ data: { meta, payload } }`, filters read/snoozed items with `includes[]=read/snoozed`, reports `count/current_page/unread_count`, serializes frontend-required notification fields, marks all notifications or a primary actor as read, returns raw update/unread/snooze notification payloads, returns unread count as a raw number, and honors `destroy_all` `type=read` from query or JSON body. Focused notification handler/service tests, combined handler/service tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder audit next.
- 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: 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.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`.
+72 -3
View File
@@ -19,6 +19,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"time" "time"
channelmodel "github.com/gochat/gochat/internal/model/channel" channelmodel "github.com/gochat/gochat/internal/model/channel"
@@ -43,10 +44,15 @@ func NewWhatsAppService(repository *Repository) *WhatsAppService {
client.SetRetryWaitTime(1 * time.Second) client.SetRetryWaitTime(1 * time.Second)
client.SetRetryMaxWaitTime(5 * time.Second) client.SetRetryMaxWaitTime(5 * time.Second)
apiVersion := os.Getenv("WHATSAPP_API_VERSION")
if apiVersion == "" {
apiVersion = "v22.0"
}
return &WhatsAppService{ return &WhatsAppService{
client: client, client: client,
repository: repository, repository: repository,
graphAPIBase: "https://graph.facebook.com/v18.0", graphAPIBase: "https://graph.facebook.com/" + apiVersion,
dialogAPIBase: "https://waba.360dialog.io", dialogAPIBase: "https://waba.360dialog.io",
} }
} }
@@ -55,7 +61,8 @@ func NewWhatsAppService(repository *Repository) *WhatsAppService {
// CreateChannel creates a new WhatsApp channel. // CreateChannel creates a new WhatsApp channel.
// Reference: Chatwoot WhatsApp channel creation flow: // Reference: Chatwoot WhatsApp channel creation flow:
// validate phone_number_id → create ChannelWhatsApp → setup webhook → create Inbox //
// validate phone_number_id → create ChannelWhatsApp → setup webhook → create Inbox
// //
// Flow: // Flow:
// 1. Validate provider (whatsapp_cloud or 360dialog) // 1. Validate provider (whatsapp_cloud or 360dialog)
@@ -325,6 +332,68 @@ func (s *WhatsAppService) FetchMessageTemplates(ctx context.Context, channel *ch
return result.Data, nil return result.Data, nil
} }
// FetchHealthStatus retrieves WhatsApp Cloud phone-number health data and formats
// it to the payload consumed by Chatwoot's inbox settings screen.
// Reference: reference/chatwoot/app/services/whatsapp/health_service.rb
func (s *WhatsAppService) FetchHealthStatus(ctx context.Context, channel *channelmodel.ChannelWhatsApp) (map[string]interface{}, error) {
if channel == nil {
return nil, fmt.Errorf("Channel is required")
}
if channel.AccessToken == "" {
return nil, fmt.Errorf("API key is missing")
}
if channel.PhoneNumberID == "" {
return nil, fmt.Errorf("Phone number ID is missing")
}
var result map[string]interface{}
url := fmt.Sprintf("%s/%s", s.graphAPIBase, channel.PhoneNumberID)
resp, err := s.client.R().
SetContext(ctx).
SetQueryParams(map[string]string{
"fields": whatsappHealthFields(),
"access_token": channel.AccessToken,
}).
SetResult(&result).
Get(url)
if err != nil {
return nil, fmt.Errorf("failed to fetch WhatsApp health status: %w", err)
}
if resp.IsError() {
return nil, fmt.Errorf("WhatsApp API request failed: %d - %s", resp.StatusCode(), resp.String())
}
return formatWhatsAppHealthResponse(result, channel), nil
}
func whatsappHealthFields() string {
return "id,quality_rating,messaging_limit_tier,code_verification_status,account_mode,display_phone_number,name_status,verified_name,webhook_configuration,throughput,last_onboarded_time,platform_type,certificate"
}
func formatWhatsAppHealthResponse(response map[string]interface{}, channel *channelmodel.ChannelWhatsApp) map[string]interface{} {
return map[string]interface{}{
"id": response["id"],
"display_phone_number": response["display_phone_number"],
"verified_name": response["verified_name"],
"name_status": response["name_status"],
"quality_rating": response["quality_rating"],
"messaging_limit_tier": response["messaging_limit_tier"],
"account_mode": response["account_mode"],
"code_verification_status": response["code_verification_status"],
"webhook_configuration": response["webhook_configuration"],
"expected_webhook_url": buildExpectedWhatsAppWebhookURL(channel.PhoneNumber),
"throughput": response["throughput"],
"last_onboarded_time": response["last_onboarded_time"],
"platform_type": response["platform_type"],
"certificate": response["certificate"],
"business_id": channel.BusinessAccountID,
}
}
func buildExpectedWhatsAppWebhookURL(phoneNumber string) string {
return fmt.Sprintf("%s/webhooks/whatsapp/%s", os.Getenv("FRONTEND_URL"), phoneNumber)
}
// === Webhook Management === // === Webhook Management ===
// SetupWebhook registers webhook subscription with WhatsApp provider. // SetupWebhook registers webhook subscription with WhatsApp provider.
@@ -484,4 +553,4 @@ func (s *WhatsAppService) deleteWebhook(ctx context.Context, channel *channelmod
// GetChannelByInboxID retrieves the WhatsApp channel associated with an inbox. // GetChannelByInboxID retrieves the WhatsApp channel associated with an inbox.
func (s *WhatsAppService) GetChannelByInboxID(ctx context.Context, inboxID uint) (*channelmodel.ChannelWhatsApp, error) { func (s *WhatsAppService) GetChannelByInboxID(ctx context.Context, inboxID uint) (*channelmodel.ChannelWhatsApp, error) {
return s.repository.GetByInboxID(ctx, inboxID) return s.repository.GetByInboxID(ctx, inboxID)
} }
+52
View File
@@ -0,0 +1,52 @@
package whatsapp
import (
"bytes"
"context"
"io"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
channelmodel "github.com/gochat/gochat/internal/model/channel"
)
func TestWhatsAppService_FetchHealthStatusFormatsChatwootPayload(t *testing.T) {
t.Setenv("FRONTEND_URL", "https://app.example.com")
svc := NewWhatsAppService(nil)
svc.graphAPIBase = "https://graph.example.test/v22.0"
svc.client.SetTransport(roundTripFunc(func(r *http.Request) (*http.Response, error) {
require.Equal(t, "/v22.0/phone-123", r.URL.Path)
assert.Contains(t, r.URL.Query().Get("fields"), "quality_rating")
assert.Equal(t, "token-abc", r.URL.Query().Get("access_token"))
body := `{"id":"phone-123","display_phone_number":"+1 555 0100","verified_name":"Support","name_status":"APPROVED","quality_rating":"GREEN","messaging_limit_tier":"TIER_1K","account_mode":"LIVE","code_verification_status":"VERIFIED","webhook_configuration":{"application":"app-1"},"throughput":{"level":"STANDARD"},"last_onboarded_time":"2026-06-05T00:00:00Z","platform_type":"CLOUD_API","certificate":"cert-data"}`
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewBufferString(body)),
}, nil
}))
channel := &channelmodel.ChannelWhatsApp{
PhoneNumber: "+1555010000",
PhoneNumberID: "phone-123",
BusinessAccountID: "waba-456",
AccessToken: "token-abc",
}
payload, err := svc.FetchHealthStatus(context.Background(), channel)
require.NoError(t, err)
assert.Equal(t, "phone-123", payload["id"])
assert.Equal(t, "GREEN", payload["quality_rating"])
assert.Equal(t, "https://app.example.com/webhooks/whatsapp/+1555010000", payload["expected_webhook_url"])
assert.Equal(t, "waba-456", payload["business_id"])
assert.NotContains(t, payload, "healthy")
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
+15 -4
View File
@@ -2,6 +2,7 @@ package v1
import ( import (
"encoding/json" "encoding/json"
"errors"
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
@@ -623,6 +624,10 @@ func (h *InboxHandler) Health(c *gin.Context) {
result, svcErr := h.svc.Health(c.Request.Context(), accountID, inboxID) result, svcErr := h.svc.Health(c.Request.Context(), accountID, inboxID)
if svcErr != nil { if svcErr != nil {
if errors.Is(svcErr, service.ErrInboxHealthWhatsAppCloudOnly) {
c.JSON(http.StatusBadRequest, gin.H{"error": service.InboxHealthWhatsAppCloudOnlyMessage})
return
}
handleServiceError(c, svcErr) handleServiceError(c, svcErr)
return return
} }
@@ -677,9 +682,11 @@ func (h *InboxHandler) RegisterWebhook(c *gin.Context) {
} }
var req service.RegisterWebhookRequest var req service.RegisterWebhookRequest
if err := c.ShouldBindJSON(&req); err != nil { if c.Request.Body != nil && c.Request.ContentLength != 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body: " + err.Error()}) if err := c.ShouldBindJSON(&req); err != nil {
return c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body: " + err.Error()})
return
}
} }
if !h.svc.Ready() { if !h.svc.Ready() {
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to register webhook") response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to register webhook")
@@ -688,11 +695,15 @@ func (h *InboxHandler) RegisterWebhook(c *gin.Context) {
svcErr := h.svc.RegisterWebhook(c.Request.Context(), accountID, inboxID, req) svcErr := h.svc.RegisterWebhook(c.Request.Context(), accountID, inboxID, req)
if svcErr != nil { if svcErr != nil {
if errors.Is(svcErr, service.ErrInboxHealthWhatsAppCloudOnly) {
c.JSON(http.StatusBadRequest, gin.H{"error": service.InboxHealthWhatsAppCloudOnlyMessage})
return
}
handleServiceError(c, svcErr) handleServiceError(c, svcErr)
return return
} }
c.JSON(http.StatusOK, gin.H{"message": "webhook registered successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Webhook registered successfully"})
} }
// GetAgentBot retrieves the currently active agent bot for an inbox. // GetAgentBot retrieves the currently active agent bot for an inbox.
+49 -130
View File
@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"os"
"strings" "strings"
"github.com/gochat/gochat/internal/campaign" "github.com/gochat/gochat/internal/campaign"
@@ -19,8 +20,16 @@ import (
) )
const InboxLimitExceededMessage = "Account limit exceeded. Upgrade to a higher plan" const InboxLimitExceededMessage = "Account limit exceeded. Upgrade to a higher plan"
const InboxHealthWhatsAppCloudOnlyMessage = "Health data only available for WhatsApp Cloud API channels"
var ErrInboxLimitExceeded = errors.New(InboxLimitExceededMessage) var ErrInboxLimitExceeded = errors.New(InboxLimitExceededMessage)
var ErrInboxHealthWhatsAppCloudOnly = errors.New(InboxHealthWhatsAppCloudOnlyMessage)
type WhatsAppChannelService interface {
FetchMessageTemplates(ctx context.Context, channel *channelmodel.ChannelWhatsApp) ([]interface{}, error)
FetchHealthStatus(ctx context.Context, channel *channelmodel.ChannelWhatsApp) (map[string]interface{}, error)
SetupWebhook(ctx context.Context, channel *channelmodel.ChannelWhatsApp, webhookURL string) error
}
// InboxService implements business logic for Inbox operations. // InboxService implements business logic for Inbox operations.
// Reference: Chatwoot app/controllers/api/v1/inboxes_controller.rb // Reference: Chatwoot app/controllers/api/v1/inboxes_controller.rb
@@ -30,7 +39,7 @@ type InboxService struct {
agentBotRepo *repository.AgentBotRepo agentBotRepo *repository.AgentBotRepo
campaignRepo *repository.CampaignRepo campaignRepo *repository.CampaignRepo
webhookSubRepo *repository.WebhookSubscriptionRepo webhookSubRepo *repository.WebhookSubscriptionRepo
whatsappService *whatsapp.WhatsAppService whatsappService WhatsAppChannelService
whatsappRepo *whatsapp.Repository whatsappRepo *whatsapp.Repository
} }
@@ -41,7 +50,7 @@ func NewInboxService(
agentBotRepo *repository.AgentBotRepo, agentBotRepo *repository.AgentBotRepo,
campaignRepo *repository.CampaignRepo, campaignRepo *repository.CampaignRepo,
webhookSubRepo *repository.WebhookSubscriptionRepo, webhookSubRepo *repository.WebhookSubscriptionRepo,
whatsappService *whatsapp.WhatsAppService, whatsappService WhatsAppChannelService,
whatsappRepo *whatsapp.Repository, whatsappRepo *whatsapp.Repository,
) *InboxService { ) *InboxService {
return &InboxService{ return &InboxService{
@@ -1595,99 +1604,25 @@ func (s *InboxService) GetAgentBot(ctx context.Context, accountID, inboxID uint)
return bot, nil return bot, nil
} }
// InboxHealthResult represents the health check result for an inbox. // Health returns Chatwoot's WhatsApp Cloud health payload for an inbox.
// Reference: Chatwoot InboxesController#health (GET member action) // Reference: Api::V1::Accounts::Concerns::WhatsappHealthManagement#health
type InboxHealthResult struct { func (s *InboxService) Health(ctx context.Context, accountID, inboxID uint) (map[string]interface{}, error) {
InboxID uint `json:"inbox_id"`
ChannelType string `json:"channel_type"`
Healthy bool `json:"healthy"`
Status string `json:"status"` // "connected", "disconnected", "misconfigured", "unknown"
Details string `json:"details,omitempty"`
}
// Health checks the health status of an inbox's channel connection.
// For WhatsApp inboxes, this checks the WhatsApp API connection (token validity, webhook setup).
// For API inboxes, this checks if the webhook_url is configured.
// For other channel types, returns basic connectivity status.
// Reference: Chatwoot InboxesController#health (uses WhatsappHealthManagement for WhatsApp)
func (s *InboxService) Health(ctx context.Context, accountID, inboxID uint) (*InboxHealthResult, error) {
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID) inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
if err != nil { if err != nil {
return nil, fmt.Errorf("inbox not found: %w", err) return nil, fmt.Errorf("inbox not found: %w", err)
} }
result := &InboxHealthResult{ if inbox.ChannelType != "whatsapp" {
InboxID: inbox.ID, return nil, ErrInboxHealthWhatsAppCloudOnly
ChannelType: inbox.ChannelType,
} }
waChannel, err := s.getWhatsAppChannel(ctx, inbox.ID)
switch inbox.ChannelType { if err != nil {
case "whatsapp": return nil, err
// WhatsApp health check: verify the channel configuration is valid
// and the connection to the WhatsApp Business API is healthy
result.Status = "connected"
result.Healthy = true
// Check if the WhatsApp channel record exists and has valid credentials
waChannel, waErr := s.getWhatsAppChannel(ctx, inbox.ID)
if waErr != nil {
result.Status = "misconfigured"
result.Healthy = false
result.Details = fmt.Sprintf("WhatsApp channel config error: %v", waErr)
} else if waChannel.ReauthorizationRequired {
result.Status = "disconnected"
result.Healthy = false
result.Details = "WhatsApp token requires reauthorization"
} else if waChannel.PhoneNumberID == "" || waChannel.AccessToken == "" {
result.Status = "misconfigured"
result.Healthy = false
result.Details = "WhatsApp channel missing phone_number_id or access_token"
} else {
result.Details = "WhatsApp Business API connection is healthy"
}
case "api":
// API inbox health: check if webhook_url and secret are configured
if inbox.WebhookURL == "" {
result.Status = "misconfigured"
result.Healthy = false
result.Details = "API inbox missing webhook_url"
} else if inbox.Secret == "" {
result.Status = "misconfigured"
result.Healthy = false
result.Details = "API inbox missing secret for webhook verification"
} else {
result.Status = "connected"
result.Healthy = true
result.Details = "API inbox webhook configured"
}
case "telegram":
// Telegram health: check if bot token is configured in channel_config
config := parseChannelConfigMap(inbox.ChannelConfig)
if _, ok := config["bot_token"]; !ok || config["bot_token"] == "" {
result.Status = "misconfigured"
result.Healthy = false
result.Details = "Telegram inbox missing bot_token"
} else {
result.Status = "connected"
result.Healthy = true
result.Details = "Telegram bot configured"
}
case "web_widget":
// Web widget doesn't have external connectivity requirements
result.Status = "connected"
result.Healthy = true
result.Details = "Web widget is always healthy"
default:
result.Status = "unknown"
result.Healthy = true
result.Details = fmt.Sprintf("Health check not implemented for channel type: %s", inbox.ChannelType)
} }
if !waChannel.IsCloudAPI() {
return result, nil return nil, ErrInboxHealthWhatsAppCloudOnly
}
return s.fetchWhatsAppHealthStatus(ctx, waChannel)
} }
// SyncTemplates syncs message templates for an inbox's channel (currently WhatsApp only). // SyncTemplates syncs message templates for an inbox's channel (currently WhatsApp only).
@@ -1725,13 +1660,12 @@ func (s *InboxService) SyncTemplates(ctx context.Context, accountID, inboxID uin
// RegisterWebhookRequest represents the request body for registering a webhook on an inbox. // RegisterWebhookRequest represents the request body for registering a webhook on an inbox.
// Reference: Chatwoot InboxesController#register_webhook (POST member action) // Reference: Chatwoot InboxesController#register_webhook (POST member action)
type RegisterWebhookRequest struct { type RegisterWebhookRequest struct {
URL string `json:"url" validate:"required,url"` URL string `json:"url"`
Events []string `json:"events,omitempty"` // e.g. ["message_created", "conversation_updated"] Events []string `json:"events,omitempty"` // e.g. ["message_created", "conversation_updated"]
} }
// RegisterWebhook registers a webhook URL with the channel provider for an inbox. // RegisterWebhook registers a webhook URL with the channel provider for an inbox.
// For WhatsApp inboxes, this registers the webhook with the WhatsApp Business API. // Chatwoot exposes this member action only for WhatsApp Cloud inboxes.
// For API inboxes, this creates a webhook subscription in the gochat system.
// Reference: Chatwoot InboxesController#register_webhook // Reference: Chatwoot InboxesController#register_webhook
func (s *InboxService) RegisterWebhook(ctx context.Context, accountID, inboxID uint, req RegisterWebhookRequest) error { func (s *InboxService) RegisterWebhook(ctx context.Context, accountID, inboxID uint, req RegisterWebhookRequest) error {
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID) inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
@@ -1739,46 +1673,24 @@ func (s *InboxService) RegisterWebhook(ctx context.Context, accountID, inboxID u
return fmt.Errorf("inbox not found: %w", err) return fmt.Errorf("inbox not found: %w", err)
} }
switch inbox.ChannelType { if inbox.ChannelType != "whatsapp" {
case "whatsapp": return ErrInboxHealthWhatsAppCloudOnly
// Register webhook with WhatsApp Business API
waChannel, err := s.getWhatsAppChannel(ctx, inbox.ID)
if err != nil {
return fmt.Errorf("failed to get WhatsApp channel: %w", err)
}
if err := s.setupWhatsAppWebhook(ctx, waChannel, req.URL); err != nil {
return fmt.Errorf("failed to register webhook with WhatsApp: %w", err)
}
applogger.L().Infof("Registered webhook for WhatsApp inbox %d (account_id=%d)", inboxID, accountID)
case "api":
// For API inboxes, store the webhook URL on the inbox and create a system webhook subscription
if err := s.repo.UpdateFields(ctx, inbox.ID, map[string]interface{}{
"webhook_url": req.URL,
}); err != nil {
return fmt.Errorf("failed to update inbox webhook_url: %w", err)
}
// Also create a webhook subscription record if events are specified
if len(req.Events) > 0 {
eventsJSON, _ := json.Marshal(req.Events)
secret := generateInboxSecret()
sub := &model.WebhookSubscription{
AccountID: accountID,
URL: req.URL,
Events: json.RawMessage(eventsJSON),
Secret: secret,
Active: true,
}
if err := s.webhookSubRepo.Create(ctx, sub); err != nil {
applogger.L().Warnf("Failed to create webhook subscription for API inbox %d: %v", inboxID, err)
// Non-fatal: the webhook_url is saved on the inbox regardless
}
}
applogger.L().Infof("Registered webhook for API inbox %d (account_id=%d)", inboxID, accountID)
default:
return fmt.Errorf("register_webhook is only supported for WhatsApp and API inboxes")
} }
waChannel, err := s.getWhatsAppChannel(ctx, inbox.ID)
if err != nil {
return fmt.Errorf("failed to get WhatsApp channel: %w", err)
}
if !waChannel.IsCloudAPI() {
return ErrInboxHealthWhatsAppCloudOnly
}
webhookURL := req.URL
if webhookURL == "" {
webhookURL = fmt.Sprintf("%s/webhooks/whatsapp/%s", strings.TrimRight(os.Getenv("FRONTEND_URL"), "/"), waChannel.PhoneNumber)
}
if err := s.setupWhatsAppWebhook(ctx, waChannel, webhookURL); err != nil {
return fmt.Errorf("failed to register webhook with WhatsApp: %w", err)
}
applogger.L().Infof("Registered webhook for WhatsApp inbox %d (account_id=%d)", inboxID, accountID)
return nil return nil
} }
@@ -1840,6 +1752,13 @@ func (s *InboxService) fetchWhatsAppTemplates(ctx context.Context, waChannel *ch
return s.whatsappService.FetchMessageTemplates(ctx, waChannel) return s.whatsappService.FetchMessageTemplates(ctx, waChannel)
} }
func (s *InboxService) fetchWhatsAppHealthStatus(ctx context.Context, waChannel *channelmodel.ChannelWhatsApp) (map[string]interface{}, error) {
if s.whatsappService == nil {
return nil, fmt.Errorf("WhatsApp service not available")
}
return s.whatsappService.FetchHealthStatus(ctx, waChannel)
}
// setupWhatsAppWebhook registers a webhook URL with the WhatsApp Business API. // setupWhatsAppWebhook registers a webhook URL with the WhatsApp Business API.
func (s *InboxService) setupWhatsAppWebhook(ctx context.Context, waChannel *channelmodel.ChannelWhatsApp, webhookURL string) error { func (s *InboxService) setupWhatsAppWebhook(ctx context.Context, waChannel *channelmodel.ChannelWhatsApp, webhookURL string) error {
if s.whatsappService == nil { if s.whatsappService == nil {
+81 -130
View File
@@ -2,7 +2,7 @@ package service
import ( import (
"context" "context"
"encoding/json" "errors"
"fmt" "fmt"
"testing" "testing"
@@ -12,7 +12,9 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
whatsappchannel "github.com/gochat/gochat/internal/channel/whatsapp"
"github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/repository"
) )
@@ -35,6 +37,7 @@ func setupInboxServiceTest(t *testing.T) (*InboxService, *gorm.DB) {
&model.Inbox{}, &model.Inbox{},
&model.AgentBotInbox{}, &model.AgentBotInbox{},
&model.WebhookSubscription{}, &model.WebhookSubscription{},
&channelmodel.ChannelWhatsApp{},
), "failed to auto-migrate") ), "failed to auto-migrate")
t.Cleanup(func() { t.Cleanup(func() {
@@ -47,11 +50,31 @@ func setupInboxServiceTest(t *testing.T) (*InboxService, *gorm.DB) {
agentBotRepo := repository.NewAgentBotRepo(db) agentBotRepo := repository.NewAgentBotRepo(db)
webhookSubRepo := repository.NewWebhookSubscriptionRepo(db) webhookSubRepo := repository.NewWebhookSubscriptionRepo(db)
// WhatsApp repos/services are nil — we only test non-WhatsApp paths waRepo := whatsappchannel.NewRepository(db)
svc := NewInboxService(repo, agentBotInboxRepo, agentBotRepo, nil, webhookSubRepo, nil, nil) svc := NewInboxService(repo, agentBotInboxRepo, agentBotRepo, nil, webhookSubRepo, nil, waRepo)
return svc, db return svc, db
} }
type fakeInboxWhatsAppService struct {
healthPayload map[string]interface{}
healthErr error
webhookURL string
webhookErr error
}
func (f *fakeInboxWhatsAppService) FetchMessageTemplates(context.Context, *channelmodel.ChannelWhatsApp) ([]interface{}, error) {
return nil, errors.New("not used")
}
func (f *fakeInboxWhatsAppService) FetchHealthStatus(_ context.Context, _ *channelmodel.ChannelWhatsApp) (map[string]interface{}, error) {
return f.healthPayload, f.healthErr
}
func (f *fakeInboxWhatsAppService) SetupWebhook(_ context.Context, _ *channelmodel.ChannelWhatsApp, webhookURL string) error {
f.webhookURL = webhookURL
return f.webhookErr
}
// createInboxTestPrereqs creates prerequisite Account and Inbox for service tests. // createInboxTestPrereqs creates prerequisite Account and Inbox for service tests.
func createInboxTestPrereqs(t *testing.T, db *gorm.DB, channelType string) (*model.Account, *model.Inbox) { func createInboxTestPrereqs(t *testing.T, db *gorm.DB, channelType string) (*model.Account, *model.Inbox) {
t.Helper() t.Helper()
@@ -70,6 +93,22 @@ func createInboxTestPrereqs(t *testing.T, db *gorm.DB, channelType string) (*mod
return account, inbox return account, inbox
} }
func createWhatsAppInboxTestPrereqs(t *testing.T, db *gorm.DB, provider string) (*model.Account, *model.Inbox, *channelmodel.ChannelWhatsApp) {
t.Helper()
account, inbox := createInboxTestPrereqs(t, db, "whatsapp")
channel := &channelmodel.ChannelWhatsApp{
AccountID: account.ID,
InboxID: inbox.ID,
PhoneNumber: "+1555010000",
PhoneNumberID: "phone-123",
BusinessAccountID: "waba-456",
AccessToken: "token-789",
Provider: provider,
}
require.NoError(t, db.Create(channel).Error)
return account, inbox, channel
}
// createTestAgentBot creates an AgentBot for the given account. // createTestAgentBot creates an AgentBot for the given account.
func createTestAgentBot(t *testing.T, db *gorm.DB, accountID uint, suffix string) *model.AgentBot { func createTestAgentBot(t *testing.T, db *gorm.DB, accountID uint, suffix string) *model.AgentBot {
t.Helper() t.Helper()
@@ -250,88 +289,41 @@ func TestInboxService_SetAgentBot_ReactivateExistingBinding(t *testing.T) {
// Health service tests // Health service tests
// ======================================== // ========================================
func TestInboxService_Health_APIInbox_Healthy(t *testing.T) { func TestInboxService_Health_NonWhatsAppInboxRejected(t *testing.T) {
svc, db := setupInboxServiceTest(t) svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "api") account, inbox := createInboxTestPrereqs(t, db, "api")
// Set webhook_url and secret on the inbox (API inbox health checks both)
require.NoError(t, db.Model(inbox).Update("webhook_url", "https://example.com/hook").Error)
require.NoError(t, db.Model(inbox).Update("secret", "my-secret-key").Error)
result, err := svc.Health(context.Background(), account.ID, inbox.ID) result, err := svc.Health(context.Background(), account.ID, inbox.ID)
require.NoError(t, err) require.ErrorIs(t, err, ErrInboxHealthWhatsAppCloudOnly)
assert.NotNil(t, result) assert.Nil(t, result)
assert.Equal(t, inbox.ID, result.InboxID)
assert.Equal(t, "api", result.ChannelType)
assert.True(t, result.Healthy)
assert.Equal(t, "connected", result.Status)
} }
func TestInboxService_Health_APIInbox_Misconfigured(t *testing.T) { func TestInboxService_Health_WhatsAppCloudReturnsPayload(t *testing.T) {
svc, db := setupInboxServiceTest(t) svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "api") account, inbox, _ := createWhatsAppInboxTestPrereqs(t, db, "whatsapp_cloud")
svc.whatsappService = &fakeInboxWhatsAppService{healthPayload: map[string]interface{}{
"id": "phone-123",
"quality_rating": "GREEN",
"expected_webhook_url": "https://app.test/webhooks/whatsapp/+1555010000",
"business_id": "waba-456",
}}
// Leave webhook_url empty — should be misconfigured
result, err := svc.Health(context.Background(), account.ID, inbox.ID) result, err := svc.Health(context.Background(), account.ID, inbox.ID)
require.NoError(t, err) require.NoError(t, err)
assert.NotNil(t, result) assert.Equal(t, "phone-123", result["id"])
assert.False(t, result.Healthy) assert.Equal(t, "GREEN", result["quality_rating"])
assert.Equal(t, "misconfigured", result.Status) assert.NotContains(t, result, "healthy")
assert.NotContains(t, result, "status")
} }
func TestInboxService_Health_APIInbox_MissingSecret(t *testing.T) { func TestInboxService_Health_NonCloudWhatsAppRejected(t *testing.T) {
svc, db := setupInboxServiceTest(t) svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "api") account, inbox, _ := createWhatsAppInboxTestPrereqs(t, db, "360dialog")
svc.whatsappService = &fakeInboxWhatsAppService{}
// Set webhook_url but leave secret empty
require.NoError(t, db.Model(inbox).Update("webhook_url", "https://example.com/hook").Error)
// secret is empty by default
result, err := svc.Health(context.Background(), account.ID, inbox.ID) result, err := svc.Health(context.Background(), account.ID, inbox.ID)
require.NoError(t, err) require.ErrorIs(t, err, ErrInboxHealthWhatsAppCloudOnly)
assert.False(t, result.Healthy) assert.Nil(t, result)
assert.Equal(t, "misconfigured", result.Status)
assert.Contains(t, result.Details, "secret")
}
func TestInboxService_Health_APIInbox_FullyConfigured(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "api")
// Set both webhook_url and secret
require.NoError(t, db.Model(inbox).Updates(map[string]interface{}{
"webhook_url": "https://example.com/hook",
"secret": "my-hmac-secret",
}).Error)
result, err := svc.Health(context.Background(), account.ID, inbox.ID)
require.NoError(t, err)
assert.True(t, result.Healthy)
assert.Equal(t, "connected", result.Status)
}
func TestInboxService_Health_WidgetInbox(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "web_widget")
result, err := svc.Health(context.Background(), account.ID, inbox.ID)
require.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.Healthy)
assert.Equal(t, "connected", result.Status)
assert.Contains(t, result.Details, "always healthy")
}
func TestInboxService_Health_UnknownChannelType(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "email")
result, err := svc.Health(context.Background(), account.ID, inbox.ID)
require.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.Healthy)
assert.Equal(t, "unknown", result.Status)
assert.Contains(t, result.Details, "not implemented")
} }
func TestInboxService_Health_InboxNotFound(t *testing.T) { func TestInboxService_Health_InboxNotFound(t *testing.T) {
@@ -385,65 +377,14 @@ func TestInboxService_SyncTemplates_WhatsAppInbox_NoWaService(t *testing.T) {
// RegisterWebhook service tests // RegisterWebhook service tests
// ======================================== // ========================================
func TestInboxService_RegisterWebhook_APIInbox(t *testing.T) { func TestInboxService_RegisterWebhook_NonWhatsAppInboxRejected(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "api")
err := svc.RegisterWebhook(context.Background(), account.ID, inbox.ID, RegisterWebhookRequest{
URL: "https://example.com/webhook",
Events: []string{"message_created", "conversation_updated"},
})
require.NoError(t, err)
// Verify the inbox webhook_url was updated
var updatedInbox model.Inbox
require.NoError(t, db.First(&updatedInbox, inbox.ID).Error)
assert.Equal(t, "https://example.com/webhook", updatedInbox.WebhookURL)
// Verify a webhook subscription was created
var subs []model.WebhookSubscription
require.NoError(t, db.Where("account_id = ?", account.ID).Find(&subs).Error)
assert.Len(t, subs, 1)
assert.Equal(t, "https://example.com/webhook", subs[0].URL)
assert.True(t, subs[0].Active)
// Verify events are stored correctly
var events []string
require.NoError(t, json.Unmarshal(subs[0].Events, &events))
assert.Contains(t, events, "message_created")
assert.Contains(t, events, "conversation_updated")
}
func TestInboxService_RegisterWebhook_APIInbox_NoEvents(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "api")
err := svc.RegisterWebhook(context.Background(), account.ID, inbox.ID, RegisterWebhookRequest{
URL: "https://example.com/webhook2",
})
require.NoError(t, err)
// Verify the inbox webhook_url was updated
var updatedInbox model.Inbox
require.NoError(t, db.First(&updatedInbox, inbox.ID).Error)
assert.Equal(t, "https://example.com/webhook2", updatedInbox.WebhookURL)
// No webhook subscription should be created when events are empty
var subs []model.WebhookSubscription
require.NoError(t, db.Where("account_id = ?", account.ID).Find(&subs).Error)
assert.Len(t, subs, 0)
}
func TestInboxService_RegisterWebhook_NonAPINonWhatsAppInbox(t *testing.T) {
svc, db := setupInboxServiceTest(t) svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "web_widget") account, inbox := createInboxTestPrereqs(t, db, "web_widget")
err := svc.RegisterWebhook(context.Background(), account.ID, inbox.ID, RegisterWebhookRequest{ err := svc.RegisterWebhook(context.Background(), account.ID, inbox.ID, RegisterWebhookRequest{
URL: "https://example.com/webhook", URL: "https://example.com/webhook",
}) })
// For channel types other than whatsapp/api, the service returns an error require.ErrorIs(t, err, ErrInboxHealthWhatsAppCloudOnly)
assert.Error(t, err)
assert.Contains(t, err.Error(), "only supported for WhatsApp and API")
} }
func TestInboxService_RegisterWebhook_InboxNotFound(t *testing.T) { func TestInboxService_RegisterWebhook_InboxNotFound(t *testing.T) {
@@ -456,13 +397,23 @@ func TestInboxService_RegisterWebhook_InboxNotFound(t *testing.T) {
assert.Contains(t, err.Error(), "inbox not found") assert.Contains(t, err.Error(), "inbox not found")
} }
func TestInboxService_RegisterWebhook_WhatsAppInbox_NoWaService(t *testing.T) { func TestInboxService_RegisterWebhook_WhatsAppCloudUsesExpectedCallbackURL(t *testing.T) {
svc, db := setupInboxServiceTest(t) svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "whatsapp") account, inbox, _ := createWhatsAppInboxTestPrereqs(t, db, "whatsapp_cloud")
fake := &fakeInboxWhatsAppService{}
svc.whatsappService = fake
t.Setenv("FRONTEND_URL", "https://app.example.com/")
err := svc.RegisterWebhook(context.Background(), account.ID, inbox.ID, RegisterWebhookRequest{ err := svc.RegisterWebhook(context.Background(), account.ID, inbox.ID, RegisterWebhookRequest{})
URL: "https://example.com/wa-webhook", require.NoError(t, err)
}) assert.Equal(t, "https://app.example.com/webhooks/whatsapp/+1555010000", fake.webhookURL)
// Will fail because whatsapp service/repo are nil }
assert.Error(t, err)
func TestInboxService_RegisterWebhook_NonCloudWhatsAppRejected(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox, _ := createWhatsAppInboxTestPrereqs(t, db, "360dialog")
svc.whatsappService = &fakeInboxWhatsAppService{}
err := svc.RegisterWebhook(context.Background(), account.ID, inbox.ID, RegisterWebhookRequest{})
require.ErrorIs(t, err, ErrInboxHealthWhatsAppCloudOnly)
} }