feat(webhooks): align account payloads

This commit is contained in:
2026-06-06 06:26:30 +08:00
parent 1adf1c9c31
commit 02ea135e0c
13 changed files with 495 additions and 94 deletions
+6
View File
@@ -172,6 +172,12 @@ var criticalRoutes = []route{
{Method: "PUT", Path: "/api/v1/accounts/:account_id/labels/:tag_id", Controller: "api/v1/accounts/labels#update", Source: "routes.rb:281"},
{Method: "DELETE", Path: "/api/v1/accounts/:account_id/labels/:tag_id", Controller: "api/v1/accounts/labels#destroy", Source: "routes.rb:281"},
{Method: "GET", Path: "/api/v1/accounts/:account_id/webhooks", Controller: "api/v1/accounts/webhooks#index", Source: "routes.rb:342"},
{Method: "POST", Path: "/api/v1/accounts/:account_id/webhooks", Controller: "api/v1/accounts/webhooks#create", Source: "routes.rb:342"},
{Method: "PUT", Path: "/api/v1/accounts/:account_id/webhooks/:webhook_id", Controller: "api/v1/accounts/webhooks#update", Source: "routes.rb:342"},
{Method: "PATCH", Path: "/api/v1/accounts/:account_id/webhooks/:webhook_id", Controller: "api/v1/accounts/webhooks#update", Source: "routes.rb:342"},
{Method: "DELETE", Path: "/api/v1/accounts/:account_id/webhooks/:webhook_id", Controller: "api/v1/accounts/webhooks#destroy", Source: "routes.rb:342"},
{Method: "GET", Path: "/api/v1/accounts/:account_id/notifications/", Controller: "api/v1/accounts/notifications#index", Source: "routes.rb:283"},
{Method: "PUT", Path: "/api/v1/accounts/:account_id/notifications/:notification_id", Controller: "api/v1/accounts/notifications#update", Source: "routes.rb:283"},
{Method: "DELETE", Path: "/api/v1/accounts/:account_id/notifications/:notification_id", Controller: "api/v1/accounts/notifications#destroy", Source: "routes.rb:283"},
+18 -12
View File
@@ -37,14 +37,14 @@ Hermes plan landing map:
## Current Baseline
- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(agent-bots): align account payloads`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(agent-bots): align account payloads`.
- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(webhooks): align account payloads`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(webhooks): align account payloads`.
- Latest documentation/tooling checkpoint: `docs: land parity execution tracker`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`.
- Worktree status at this implementation checkpoint: P3.9 account agent-bot API parity is now implemented for reused dashboard `AgentBotsAPI` and `agentBots` store flows. Account `/agent_bots` now exposes frontend no-trailing-slash GET/POST and PATCH update routes, tracks Chatwoot `routes.rb:94-97`, returns raw AgentBot Jbuilder-style arrays/objects instead of local success/data/meta envelopes, accepts JSON or multipart form bodies, preserves explicit empty update fields, scopes mutations to `Current.account.agent_bots`, allows show/index of system bots through `accessible_to`, returns empty `200 OK` delete, and returns full bot payloads for reset-access-token/reset-secret/avatar actions. P6.8 contact outbound voice call, P3.7d enterprise help-center article translation, P6.5 inbox agent-bot member actions, P6 message retry parity, P6 conversation custom-attribute response parity, and P3.7a-P3.7c help-center payload parity remain in Review. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is another Phase 6 placeholder burn-down item or a new Phase 2/3 drift slice from fresh reference/smoke evidence.
- Worktree status at this implementation checkpoint: P3.10 account webhook API parity is now implemented for reused dashboard `webhooks` settings flows. Account `/webhooks` now tracks Chatwoot `routes.rb:342`, exposes frontend `PATCH` update, returns Chatwoot Jbuilder `{ payload: { webhooks } }` and `{ payload: { webhook } }` payloads, accepts nested `{ webhook: ... }` request bodies plus legacy raw bodies, persists `name`, `inbox_id`, `url`, `subscriptions`, generated `secret`, scopes show/update/delete through current account, validates Chatwoot webhook events and HTTP/HTTPS URLs, returns empty `200 OK` delete, and keeps outgoing delivery event filtering on the same subscription storage. P3.9 account agent-bot API, P6.8 contact outbound voice call, P3.7d enterprise help-center article translation, P6.5 inbox agent-bot member actions, P6 message retry parity, P6 conversation custom-attribute response parity, and P3.7a-P3.7c help-center payload parity remain in Review. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is another Phase 6 placeholder burn-down item or a new Phase 2/3 drift slice from fresh reference/smoke evidence.
- `go test ./...` passes.
- Route dump succeeds with `TOTAL: 864` after adding account agent-bot no-trailing-slash and PATCH update routes.
- Route dump succeeds with `TOTAL: 865` after adding account webhook PATCH update route.
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
- Tracked frontend-critical route audit covers 315 Chatwoot routes: 308 exact, 0 method-compatible, 7 parameter-compatible, 0 missing. The 7 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits; the external URL shape is equivalent.
- Tracked frontend-critical route audit covers 320 Chatwoot routes: 313 exact, 0 method-compatible, 7 parameter-compatible, 0 missing. The 7 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits; the external URL shape is equivalent.
- `/api/v1/widget` stubs are burned down and public inbox/contact/conversation/message core flows are backed by real handlers.
- Handler test stability fixes are committed into the baseline before feature parity work continues.
- `.codegraph/` is generated indexing output and is not part of tracked product code.
@@ -126,13 +126,14 @@ This table is the shortest authoritative handoff view. If an older lower section
| Priority | Workstream | Current state | Next checkpoint | Commit close rule |
| --- | --- | --- | --- | --- |
| 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 315-route critical set; help-center portal/category/article routes from `routes.rb:385-404`, enterprise contact outbound voice call from `routes.rb:216`, and account agent-bot routes from `routes.rb:94-97` are now explicitly tracked. Notification list/action serializers, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, account agent-bot route/payload/mutation behavior, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, and help-center portal/category/article payloads now match the inspected Chatwoot frontend contract. | Run the next evidence-backed route/controller/serializer drift audit. | 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 320-route critical set; help-center portal/category/article routes from `routes.rb:385-404`, enterprise contact outbound voice call from `routes.rb:216`, account agent-bot routes from `routes.rb:94-97`, and account webhook routes from `routes.rb:342` are now explicitly tracked. Notification list/action serializers, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, account agent-bot route/payload/mutation behavior, account webhook payload/mutation behavior, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, and help-center portal/category/article payloads now match the inspected Chatwoot frontend contract. | Run the next evidence-backed route/controller/serializer drift audit. | 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; inbox WhatsApp health/register-webhook and sync-template drift are closed; fresh `rg` audit shows only webhook nil-handler fallbacks still call `chatwootParityStub`; dashboard conversation transcript/custom-attribute response drift and message retry status drift are closed. | Continue targeted account/contact/conversation/message/inbox drift from reference/smoke evidence. | `rg` placeholder audit is recorded and no reused-frontend blocker is ownerless. |
| 3 | P3.9 account agent-bot API | Implemented for the reused dashboard AgentBots settings route with no-trailing-slash routes, PATCH update, raw Jbuilder-style payloads, account mutation scope, system-bot show/list visibility, empty `200 OK` delete, and full reset/avatar action payloads. | Keep in Review; reopen only if live settings smoke exposes avatar upload storage or administrator-secret gating drift. | Focused AgentBot handler tests, service/router focused tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
| 4 | P6.8 contact outbound voice call | Implemented for the reused dashboard route with Twilio voice-enabled inbox validation, assigned-inbox check, open-conversation reuse, ContactInbox/conversation/call/message persistence, and Chatwoot response shape. | Keep in Review; reopen only if live smoke exposes provider initiation/status-update drift beyond the fakeable persisted boundary. | Focused contact call tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
| 5 | 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. |
| 6 | 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. |
| 7 | 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. |
| 4 | P3.10 account webhooks API | Implemented for the reused dashboard Webhooks settings route with PATCH update, Chatwoot `{ payload }` list/mutation serializers, nested `{ webhook: ... }` bodies, generated secret, account-scoped mutations, URL/subscription validation, optional inbox serialization, and empty `200 OK` delete. | Keep in Review; reopen only if live settings smoke exposes audit writer or delivery-signature drift beyond the existing delivery service boundary. | Focused webhook handler/service/router tests, migration test, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
| 5 | P6.8 contact outbound voice call | Implemented for the reused dashboard route with Twilio voice-enabled inbox validation, assigned-inbox check, open-conversation reuse, ContactInbox/conversation/call/message persistence, and Chatwoot response shape. | Keep in Review; reopen only if live smoke exposes provider initiation/status-update drift beyond the fakeable persisted boundary. | Focused contact call tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
| 6 | 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. |
| 7 | 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. |
| 8 | 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
@@ -152,6 +153,7 @@ These rows are the executable development plan from this point forward. A checkp
| P3.7c help center article and bulk-action parity | `internal/router/router.go`, `internal/handler/api/v1/article_handler.go`, `internal/service/article_service.go`, `internal/repository/article_repo.go`, article tests | `reference/chatwoot/app/controllers/api/v1/accounts/articles_controller.rb`, `app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb`, `app/views/api/v1/accounts/articles/*.json.jbuilder`, dashboard `api/helpCenter/articles.js` | Article APIs now resolve portal slugs account-scoped with numeric fallback, accept raw article bodies plus wrappers, expose no-trailing-slash index/create plus frontend `PATCH`, return Chatwoot `{ payload, meta }` list/search payloads and `{ payload: article }` show/create/update/edit payloads, serialize category/author/views/Unix updated-at/meta/associated articles, make delete/reorder/bulk actions empty `200 OK`, accept Chatwoot `positions_hash`, scope mutations to the resolved portal, and implement frontend bulk update-status/update-category/delete routes. | Review by `feat(help-center): align article payloads`; focused article handler tests, service/repository article tests, router test, route dump/parity regeneration, escalated full `go test ./...`, and `git diff --check` passed. |
| P3.7d enterprise article bulk translation | `internal/handler/api/v1/article_handler.go`, `internal/service/article_service.go`, `internal/repository/article_repo.go`, `internal/app/bootstrap.go`, article handler/service tests | `reference/chatwoot/enterprise/app/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller.rb`, `enterprise/app/jobs/captain/articles/translate_job.rb`, `enterprise/app/services/captain/llm/article_translation_service.rb`, dashboard `api/helpCenter/articles.js`, `BulkTranslateDialog.vue` | Article bulk translate now validates `captain_tasks`, portal allowed locales, optional target-locale category, and selected articles; detects existing root-article translations and returns `409 { duplicate_articles }` unless `force` is true; queues durable low-priority `captain:article_translate` jobs; maps locale codes to English language names; and creates or updates draft translated articles through the wired LLM translation backend using Chatwoot's title/content prompts. | Review by `feat(help-center): queue article translations`; focused service/handler tests, combined service/handler/repository/app tests, full `go test ./...`, and `git diff --check` passed. No route changes. |
| P3.9 account agent-bot API parity | `internal/router/router.go`, `internal/handler/api/v1/agent_bot_handler.go`, `internal/service/agent_bot_service.go`, `internal/repository/agent_bot_repo.go`, agent bot handler tests | `reference/chatwoot/config/routes.rb:94-97`, `reference/chatwoot/app/controllers/api/v1/accounts/agent_bots_controller.rb`, `app/views/api/v1/accounts/agent_bots/*.json.jbuilder`, `app/views/api/v1/models/_agent_bot.json.jbuilder`, `app/models/agent_bot.rb`, `app/presenters/agent_bot_presenter.rb`, dashboard `api/agentBots.js`, `store/modules/agentBots.js`, settings `agentBots` route/components | Account agent-bot APIs now expose frontend no-trailing-slash GET/POST and PATCH update routes, track the full Chatwoot account agent-bot route set, return raw Jbuilder-style arrays/objects with `thumbnail`, `bot_config`, `system_bot`, account-scoped `access_token`/`secret`, accept JSON or multipart form bodies, preserve explicit empty update fields, scope create/update/delete/reset/avatar mutations to current-account bots, allow show/index of system bots through `accessible_to`, return empty `200 OK` delete, and return full bot payloads after reset-access-token/reset-secret/avatar deletion. | Review by `feat(agent-bots): align account payloads`; focused AgentBot handler tests, `go test ./internal/handler/api/v1 ./internal/service ./internal/router -run 'AgentBot\|Router' -count=1`, route dump/parity regenerated to `TOTAL: 864` and `308 exact, 7 parameter-compatible, 0 missing out of 315`, full `go test ./...`, and `git diff --check` passed. |
| P3.10 account webhook API parity | `internal/router/router.go`, `internal/handler/api/v1/webhook_subscription_handler.go`, `internal/service/webhook_subscription_service.go`, `internal/repository/webhook_subscription_repo.go`, `internal/model/webhook_subscription.go`, webhook handler/service tests, migrations | `reference/chatwoot/config/routes.rb:342`, `reference/chatwoot/app/controllers/api/v1/accounts/webhooks_controller.rb`, `app/views/api/v1/accounts/webhooks/*.json.jbuilder`, `app/models/webhook.rb`, dashboard `api/webhooks.js`, `store/modules/webhooks.js`, settings `integrations/Webhooks/*` components | Account webhook APIs now match the reused dashboard settings flow: index returns `{ payload: { webhooks } }`, create/update return `{ payload: { webhook } }`, delete returns empty `200 OK`, frontend `PATCH` update is registered, nested `{ webhook: ... }` request bodies are accepted, `name/url/subscriptions/inbox_id/secret` serialize like Jbuilder, mutations are scoped through current account, URL/subscription validation follows the reference allowed event set, and outgoing delivery filtering keeps using the same persisted subscription rows. | Review by `feat(webhooks): align account payloads`; focused webhook handler/service tests, router focused test, `go test ./cmd/migrate -count=1`, route dump/parity regenerated to `TOTAL: 865` and `313 exact, 7 parameter-compatible, 0 missing out of 320`, full `go test ./...`, and `git diff --check` passed. |
| P6.8 contact outbound voice call parity | `internal/router/router.go`, `internal/handler/api/v1/contact_handler.go`, `internal/service/contact_service.go`, `internal/model/call.go`, contact handler tests | `reference/chatwoot/config/routes.rb:216`, `reference/chatwoot/enterprise/app/controllers/api/v1/accounts/contacts/calls_controller.rb`, `reference/chatwoot/enterprise/app/services/voice/outbound_call_builder.rb`, `reference/chatwoot/enterprise/app/services/voice/call_message_builder.rb`, `reference/chatwoot/enterprise/app/models/call.rb`, dashboard `api/contacts.js`, `store/modules/contacts/actions.js`, `api/channel/voice/voiceAPIClient.js` | Contact outbound calls now match the Chatwoot enterprise route boundary: account contact lookup, current user's assigned `Channel::TwilioSms` inbox lookup, `voice_enabled` guard, phone-number guard, open display-ID conversation reuse only for same inbox/contact, new ContactInbox/open conversation creation when needed, persisted outgoing Twilio call metadata, linked `voice_call` message content attributes, and raw `{ conversation_id, inbox_id, call_sid, conference_sid }` response. | Review by `feat(contacts): initiate voice calls`; focused contact call tests cover success/reuse/resolved-hint ignored/no-phone/non-voice/unassigned cases; route dump/parity regenerated to `TOTAL: 861` and `299 exact, 7 parameter-compatible, 0 missing out of 306`; full `go test ./...` and `git diff --check` passed. |
| P6 conversation transcript response parity | `internal/handler/api/v1/conversation_handler.go`, conversation handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb`, dashboard `api/inbox/conversation.js` | Account conversation transcript now follows Chatwoot's controller contract: missing email returns `422 { error: "email param missing" }`, nonblank email schedules through the existing service boundary and returns empty `200 OK`, and invalid-looking but nonblank email values are not rejected by local email format validation. | Review by `feat(conversations): align transcript responses`; focused transcript handler/service tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` must pass. |
| P6 conversation custom attributes response parity | `internal/handler/api/v1/conversation_handler.go`, conversation handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb`, `app/views/api/v1/accounts/conversations/custom_attributes.json.jbuilder`, dashboard `api/inbox/conversation.js`, conversation store action | Account conversation custom attribute updates now return Chatwoot `{ custom_attributes: ... }` only, matching the store action that reads `response.data.custom_attributes`, instead of returning the full conversation serializer with unrelated fields. Empty/null JSON serializes as `{}`. | Review by `feat(conversations): align custom attribute response`; focused handler tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` passed. |
@@ -191,6 +193,7 @@ This ledger records the committed parity checkpoints that future slices should b
| Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- |
| `feat(webhooks): align account payloads` | Advances P3.10 account webhook API parity by matching Chatwoot `WebhooksController`, account webhook Jbuilder views, `Webhook` model validation, and the reused dashboard `webhooks` API/store/settings components. GoChat now tracks `routes.rb:342`, registers frontend `PATCH /api/v1/accounts/:account_id/webhooks/:webhook_id`, returns Chatwoot `{ payload: { webhooks } }` and `{ payload: { webhook } }` payloads, accepts nested `{ webhook: ... }` bodies, persists `name`, `inbox_id`, `url`, `subscriptions`, and generated `secret`, scopes show/update/delete by account, validates HTTP/HTTPS URLs plus the reference allowed event set, returns empty `200 OK` delete, and keeps delivery filtering backed by persisted subscription rows. | `go test ./internal/handler/api/v1 -run WebhookSubscription -count=1`; `go test ./internal/service -run WebhookSubscription -count=1`; `go test ./internal/router -run Router -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/router ./cmd/route_parity -run 'WebhookSubscription\|Router\|RouteParity' -count=1`; `go test ./cmd/migrate -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./...`; `git diff --check`. Route dump is `TOTAL: 865`; tracked route parity is `313 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 320`. | P3.10 moves to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
| `feat(agent-bots): align account payloads` | Advances P3.9 account agent-bot API parity by matching Chatwoot `AgentBotsController`, account agent-bot Jbuilder views, `_agent_bot.json.jbuilder`, `AgentBotPresenter`, and the reused dashboard `AgentBotsAPI`/`agentBots` store path. GoChat now registers frontend no-trailing-slash `GET/POST /api/v1/accounts/:account_id/agent_bots` plus `PATCH /agent_bots/:id`, tracks `routes.rb:94-97`, returns raw bot arrays/objects with `thumbnail`, `bot_config`, `system_bot`, account-scoped `access_token`/`secret`, accepts JSON and multipart form bodies, preserves explicit empty update values, scopes mutation/reset/avatar actions to current-account bots while allowing index/show of system bots, returns empty `200 OK` delete, and returns full bot payloads for reset/avatar actions. | `go test ./internal/handler/api/v1 -run AgentBot -count=1`; `go test ./internal/service -run AgentBot -count=1`; `go test ./internal/router -run Router -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/router -run 'AgentBot\|Router' -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./cmd/route_parity -count=1`; `go test ./...`; `git diff --check`. Route dump is `TOTAL: 864`; tracked route parity is `308 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 315`. | P3.9 moves to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
| `feat(contacts): initiate voice calls` | Advances P6.8 contact outbound voice call parity by matching Chatwoot enterprise `Contacts::CallsController#create`, `Voice::OutboundCallBuilder`, `Voice::CallMessageBuilder`, `Call`, and the reused dashboard `ContactAPI.initiateCall`/voice client path. GoChat now registers `POST /api/v1/accounts/:account_id/contacts/:contact_id/call`, requires an assigned `Channel::TwilioSms` inbox with `voice_enabled`, requires a contact phone number, reuses only matching open display-ID conversations, creates ContactInbox/open conversation records when needed, persists Twilio outgoing call metadata and a linked `voice_call` message payload, and returns `{ conversation_id, inbox_id, call_sid, conference_sid }`. | `go test ./internal/handler/api/v1 -run 'ContactHandlerCRUDTestSuite/TestInitiateCall' -count=1`; `go test ./internal/service ./internal/handler/api/v1 ./internal/router -run 'Contact\|Router' -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./cmd/route_parity -count=1`; `go test ./...`; `git diff --check`. Route dump is `TOTAL: 861`; tracked route parity is `299 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 306`. | P6.8 moves to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
| `docs: land parity execution tracker` | Documentation-only checkpoint that turns the Hermes-era search and automation/macro/CSAT notes, user-confirmed enterprise scope, latest help-center baseline, and fresh contact voice-call drift into one executable queue. Adds lifecycle rules, ordered next slices, explicit P6.8 contact outbound voice-call contract, and updates the active board so future work can be tracked without reopening `.hermes/plans/*`. | `git diff --check`. No Go code changed and no route artifacts changed. | Start P6.8 contact outbound voice-call parity, or run the next Phase 2/3 plus Phase 6 evidence audit before implementation. |
@@ -1622,7 +1625,7 @@ Status: doing.
Checklist:
- [ ] Compare key Chatwoot serializers/entities with Go response payloads.
- [ ] Align account, user, inbox, conversation, message, contact, company, team, label, canned response, campaign, help center, and notification payload shapes.
- [ ] Align account, user, inbox, conversation, message, contact, company, team, label, canned response, campaign, webhook, help center, and notification payload shapes.
- [ ] Verify timestamps, IDs, enum strings, nested objects, pagination metadata, and error envelopes.
- [ ] Add fixture-driven tests for payload compatibility.
@@ -1644,6 +1647,7 @@ Frontend-critical API groups to audit first:
| P3.7 | Notifications/reports/help center/campaigns | Frontend-visible payloads and pagination/error envelopes. | Doing; notifications, reports, account campaign CRUD/scheduling, and help-center portal/category/article routes/payloads now have focused Chatwoot payload fixtures. Continue only with fresh reference/smoke drift. |
| P3.8 | Widget/public APIs | Widget init, campaigns, config, contact, conversations, messages, direct uploads, public inbox flow, public CSAT. | Doing |
| P3.9 | Search payloads | Global search and entity search documents backed by Meilisearch. | Review |
| P3.10 | Account webhooks/integration settings | Account webhook CRUD, payloads, event subscriptions, secrets, and delivery backing rows. | Review; reused dashboard Webhooks settings flow now gets Chatwoot `{ payload }` list/mutation serializers, PATCH update, nested `{ webhook }` bodies, generated secrets, scoped mutations, and empty `200 OK` delete. |
Serializer parity work plan:
@@ -1656,7 +1660,8 @@ Serializer parity work plan:
| S5 | Notifications and settings | `reference/chatwoot/app/controllers/api/v1/accounts/notifications*` | fixture tests for notification list/actions/settings | Review; list envelope, includes filters, unread counts, read_all, unread/snooze/update raw responses, and destroy_all read/all are covered. |
| S6 | Reports and CSAT | `reference/chatwoot/app/controllers/api/v1/accounts/reports*`, `csat_survey_responses*` | fixture tests for report filters and CSAT metrics/list | Doing |
| S7 | Widget/public | `reference/chatwoot/app/controllers/api/v1/widget*`, `public/api/v1*` | widget smoke fixtures and public flow tests | Doing |
| S8 | Search | `reference/chatwoot` search controllers plus frontend search client | Meilisearch-backed search response fixtures | Todo |
| S8 | Search | `reference/chatwoot` search controllers plus frontend search client | Meilisearch-backed search response fixtures | Review |
| S9 | Account webhooks | `reference/chatwoot/app/controllers/api/v1/accounts/webhooks_controller.rb`, account webhook Jbuilder views, dashboard webhooks store/API | fixture tests for webhook list/create/update/delete payloads | Review |
Serializer comparison rules:
@@ -2150,6 +2155,7 @@ Verification milestone gates:
## Progress Log
- 2026-06-06: P3.10 account webhook API checkpoint prepared as `feat(webhooks): align account payloads`; audited Chatwoot `routes.rb:342`, `WebhooksController`, account webhook Jbuilder views, `Webhook`, reused dashboard `api/webhooks.js`, `store/modules/webhooks.js`, and settings Webhooks components. Account webhook APIs now return Chatwoot `{ payload: { webhooks } }` list payloads and `{ payload: { webhook } }` mutation/show payloads, accept nested `{ webhook: ... }` frontend bodies plus legacy raw bodies, register frontend `PATCH` update, persist `name/url/subscriptions/inbox_id/secret`, scope show/update/delete by account, validate HTTP/HTTPS URLs and allowed subscriptions, return empty `200 OK` delete, and keep outbound delivery event filtering on the same rows. Focused webhook handler/service/router tests, migration test, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed; route dump is `TOTAL: 865` and tracked route parity is `313 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 320`.
- 2026-06-06: P3.9 account agent-bot API checkpoint prepared as `feat(agent-bots): align account payloads`; audited Chatwoot `routes.rb:94-97`, `AgentBotsController`, account agent-bot Jbuilder views, `_agent_bot.json.jbuilder`, `AgentBotPresenter`, `AgentBot`, and reused dashboard `api/agentBots.js`, `store/modules/agentBots.js`, and settings agent-bot screens. Account agent-bot APIs now expose frontend no-trailing-slash GET/POST and PATCH update routes, return raw Chatwoot bot arrays/objects instead of local success/data/meta envelopes, serialize `thumbnail`, `bot_config`, `system_bot`, account-scoped `access_token`/`secret`, accept JSON or multipart form bodies, preserve explicit empty update fields, scope create/update/delete/reset/avatar mutations to current-account bots, allow show/index of system bots through `accessible_to`, return empty `200 OK` delete, and return full bot payloads after reset-access-token/reset-secret/avatar deletion. Focused AgentBot handler/service/router tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed; route dump is `TOTAL: 864` and tracked route parity is `308 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 315`.
- 2026-06-06: P6.8 contact outbound voice call checkpoint prepared as `feat(contacts): initiate voice calls`; audited Chatwoot `routes.rb:216`, enterprise `Contacts::CallsController#create`, `Voice::OutboundCallBuilder`, `Voice::CallMessageBuilder`, `Call`, and reused dashboard `api/contacts.js`, contact store action, and voice API client. GoChat now registers `POST /api/v1/accounts/:account_id/contacts/:contact_id/call`, validates account contact scope, current user's assigned Twilio voice inbox, `voice_enabled`, and contact phone number, reuses only matching open display-ID conversations, creates ContactInbox/open conversation rows when needed, persists outgoing Twilio call metadata and linked `voice_call` message content attributes, and returns `{ conversation_id, inbox_id, call_sid, conference_sid }`. Focused contact-call tests, combined service/handler/router tests, route dump/parity regeneration, `go test ./cmd/route_parity -count=1`, full `go test ./...`, and `git diff --check` passed; route dump is `TOTAL: 861` and tracked route parity is `299 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 306`.
- 2026-06-06: Documentation checkpoint prepared as `docs: land parity execution tracker`; consolidated the active execution queue so future work can be followed from `docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md` alone. The tracker now pins current baseline commits, maps Hermes source plans to active sections, defines slice lifecycle states, records the ordered next queue, and opens P6.8 for Chatwoot enterprise contact outbound voice calls after inspecting `reference/chatwoot/config/routes.rb:216`, `enterprise/app/controllers/api/v1/accounts/contacts/calls_controller.rb`, `enterprise/app/services/voice/outbound_call_builder.rb`, and reused dashboard contact/voice API clients. Verification for this docs-only checkpoint: `git diff --check`; no Go code or route artifacts changed.
+2 -1
View File
@@ -495,6 +495,7 @@ PATCH /api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions/upda
PATCH /api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions/update_status
PATCH /api/v1/accounts/:account_id/portals/:portal_id/categories/:category_id
PATCH /api/v1/accounts/:account_id/teams/:team_id/team_members/
PATCH /api/v1/accounts/:account_id/webhooks/:webhook_id
PATCH /api/v1/widget/contact
PATCH /api/v1/widget/contact/set_user
PATCH /api/v1/widget/messages/:message_id
@@ -862,4 +863,4 @@ PUT /public/api/v1/csat_survey/:id
PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id
PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id
PUT /widget/direct_uploads/:upload_uuid
TOTAL: 864
TOTAL: 865
+6 -1
View File
@@ -7,7 +7,7 @@ Generated from:
This report covers tracked frontend-critical Chatwoot routes from `reference/chatwoot/config/routes.rb`, including API v1 account routes, Captain/Copilot, assignment policies, widget/public APIs, and API v2 reports. Ruby is not installed in the workspace, so Chatwoot routes are sourced from static route declarations instead of `bin/rails routes`.
Summary: 308 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 315 tracked critical routes.
Summary: 313 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 320 tracked critical routes.
## Missing Critical Routes
@@ -79,6 +79,7 @@ These routes exist with equivalent method and path shape but different parameter
| DELETE | `/api/v1/accounts/:account_id/sla_policies/:id` | `/api/v1/accounts/:account_id/sla_policies/:id` | `api/v1/accounts/sla_policies#destroy` | `routes.rb:121` | exact |
| DELETE | `/api/v1/accounts/:account_id/teams/:team_id` | `/api/v1/accounts/:account_id/teams/:team_id` | `api/v1/accounts/teams#destroy` | `routes.rb:296` | exact |
| DELETE | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `api/v1/accounts/teams/team_members#destroy` | `routes.rb:299` | exact |
| DELETE | `/api/v1/accounts/:account_id/webhooks/:webhook_id` | `/api/v1/accounts/:account_id/webhooks/:webhook_id` | `api/v1/accounts/webhooks#destroy` | `routes.rb:342` | exact |
| DELETE | `/api/v1/widget/labels/:label_id` | `/api/v1/widget/labels/:label_id` | `api/v1/widget/labels#destroy` | `routes.rb:464` | exact |
| GET | `/api/v1/accounts/:account_id` | `/api/v1/accounts/:account_id` | `api/v1/accounts#show` | `routes.rb:47` | exact |
| GET | `/api/v1/accounts/:account_id/agent_bots` | `/api/v1/accounts/:account_id/agent_bots` | `api/v1/accounts/agent_bots#index` | `routes.rb:94` | exact |
@@ -174,6 +175,7 @@ These routes exist with equivalent method and path shape but different parameter
| GET | `/api/v1/accounts/:account_id/teams/` | `/api/v1/accounts/:account_id/teams/` | `api/v1/accounts/teams#index` | `routes.rb:296` | exact |
| GET | `/api/v1/accounts/:account_id/teams/:team_id` | `/api/v1/accounts/:account_id/teams/:team_id` | `api/v1/accounts/teams#show` | `routes.rb:296` | exact |
| GET | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `api/v1/accounts/teams/team_members#index` | `routes.rb:297` | exact |
| GET | `/api/v1/accounts/:account_id/webhooks` | `/api/v1/accounts/:account_id/webhooks` | `api/v1/accounts/webhooks#index` | `routes.rb:342` | exact |
| GET | `/api/v1/widget/campaigns` | `/api/v1/widget/campaigns` | `api/v1/widget/campaigns#index` | `routes.rb:445` | exact |
| GET | `/api/v1/widget/contact` | `/api/v1/widget/contact` | `api/v1/widget/contact#show` | `routes.rb:458` | exact |
| GET | `/api/v1/widget/conversations` | `/api/v1/widget/conversations` | `api/v1/widget/conversations#index` | `routes.rb:448` | exact |
@@ -216,6 +218,7 @@ These routes exist with equivalent method and path shape but different parameter
| PATCH | `/api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions/update_status` | `/api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions/update_status` | `api/v1/accounts/articles/bulk_actions#update_status` | `routes.rb:398` | exact |
| PATCH | `/api/v1/accounts/:account_id/portals/:portal_id/categories/:category_id` | `/api/v1/accounts/:account_id/portals/:portal_id/categories/:category_id` | `api/v1/accounts/categories#update` | `routes.rb:392` | exact |
| PATCH | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `api/v1/accounts/teams/team_members#update` | `routes.rb:300` | exact |
| PATCH | `/api/v1/accounts/:account_id/webhooks/:webhook_id` | `/api/v1/accounts/:account_id/webhooks/:webhook_id` | `api/v1/accounts/webhooks#update` | `routes.rb:342` | exact |
| PATCH | `/api/v1/widget/contact/set_user` | `/api/v1/widget/contact/set_user` | `api/v1/widget/contact#set_user` | `routes.rb:461` | exact |
| POST | `/api/v1/accounts/` | `/api/v1/accounts/` | `api/v1/accounts#create` | `routes.rb:47` | exact |
| POST | `/api/v1/accounts/:account_id/actions/contact_merge` | `/api/v1/accounts/:account_id/actions/contact_merge` | `api/v1/accounts/actions/contact_merges#create` | `routes.rb:55` | exact |
@@ -296,6 +299,7 @@ These routes exist with equivalent method and path shape but different parameter
| POST | `/api/v1/accounts/:account_id/teams/` | `/api/v1/accounts/:account_id/teams/` | `api/v1/accounts/teams#create` | `routes.rb:296` | exact |
| POST | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `api/v1/accounts/teams/team_members#create` | `routes.rb:297` | exact |
| POST | `/api/v1/accounts/:account_id/update_active_at` | `/api/v1/accounts/:account_id/update_active_at` | `api/v1/accounts#update_active_at` | `routes.rb:49` | exact |
| POST | `/api/v1/accounts/:account_id/webhooks` | `/api/v1/accounts/:account_id/webhooks` | `api/v1/accounts/webhooks#create` | `routes.rb:342` | exact |
| POST | `/api/v1/widget/config` | `/api/v1/widget/config` | `api/v1/widget/config#create` | `routes.rb:444` | exact |
| POST | `/api/v1/widget/contact/destroy_custom_attributes` | `/api/v1/widget/contact/destroy_custom_attributes` | `api/v1/widget/contact#destroy_custom_attributes` | `routes.rb:460` | exact |
| POST | `/api/v1/widget/conversations` | `/api/v1/widget/conversations` | `api/v1/widget/conversations#create` | `routes.rb:448` | exact |
@@ -344,6 +348,7 @@ These routes exist with equivalent method and path shape but different parameter
| PUT | `/api/v1/accounts/:account_id/portals/:portal_id/categories/:category_id` | `/api/v1/accounts/:account_id/portals/:portal_id/categories/:category_id` | `api/v1/accounts/categories#update` | `routes.rb:392` | exact |
| PUT | `/api/v1/accounts/:account_id/sla_policies/:id` | `/api/v1/accounts/:account_id/sla_policies/:id` | `api/v1/accounts/sla_policies#update` | `routes.rb:121` | exact |
| PUT | `/api/v1/accounts/:account_id/teams/:team_id` | `/api/v1/accounts/:account_id/teams/:team_id` | `api/v1/accounts/teams#update` | `routes.rb:296` | exact |
| PUT | `/api/v1/accounts/:account_id/webhooks/:webhook_id` | `/api/v1/accounts/:account_id/webhooks/:webhook_id` | `api/v1/accounts/webhooks#update` | `routes.rb:342` | exact |
| PUT | `/api/v1/widget/contact` | `/api/v1/widget/contact` | `api/v1/widget/contact#update` | `routes.rb:458` | exact |
| PUT | `/api/v1/widget/messages/:message_id` | `/api/v1/widget/messages/:message_id` | `api/v1/widget/messages#update` | `routes.rb:447` | exact |
| PUT | `/public/api/v1/csat_survey/:id` | `/public/api/v1/csat_survey/:id` | `public/api/v1/csat_survey#update` | `routes.rb:585` | exact |
@@ -1,10 +1,14 @@
package v1
import (
"encoding/json"
"errors"
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/service"
"github.com/gochat/gochat/pkg/response"
)
@@ -35,7 +39,7 @@ func (h *WebhookSubscriptionHandler) List(c *gin.Context) {
return
}
response.OK(c, gin.H{"webhook_subscriptions": subscriptions})
c.JSON(http.StatusOK, gin.H{"payload": gin.H{"webhooks": serializeWebhookSubscriptions(subscriptions)}})
}
// Get returns a single webhook subscription by ID.
@@ -45,19 +49,20 @@ func (h *WebhookSubscriptionHandler) Get(c *gin.Context) {
response.AbortWithStatusError(c, http.StatusServiceUnavailable, response.ErrInternal, "Webhook subscription service not available")
return
}
accountID := getAccountID(c)
webhookID, err := parseUintParam(c, "webhook_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid webhook id")
return
}
subscription, svcErr := h.webhookSubscriptionService.GetSubscription(c.Request.Context(), webhookID)
subscription, svcErr := h.webhookSubscriptionService.GetWebhook(c.Request.Context(), accountID, webhookID)
if svcErr != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "Failed to fetch webhook subscription")
abortWebhookSubscriptionError(c, svcErr)
return
}
response.OK(c, gin.H{"webhook_subscription": subscription})
c.JSON(http.StatusOK, gin.H{"payload": gin.H{"webhook": serializeWebhookSubscription(*subscription)}})
}
// Create adds a new webhook subscription for an account.
@@ -69,22 +74,19 @@ func (h *WebhookSubscriptionHandler) Create(c *gin.Context) {
}
accountID := getAccountID(c)
var req struct {
URL string `json:"url" binding:"required"`
Events []string `json:"events" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "Invalid request: url and events are required")
var req service.WebhookSubscriptionMutation
if err := bindJSONWrappedOrRaw(c, "webhook", &req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "Invalid request body")
return
}
subscription, err := h.webhookSubscriptionService.CreateSubscription(c.Request.Context(), accountID, req.URL, req.Events)
subscription, err := h.webhookSubscriptionService.CreateWebhook(c.Request.Context(), accountID, req)
if err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "Failed to create webhook subscription")
abortWebhookSubscriptionError(c, err)
return
}
response.Created(c, subscription)
c.JSON(http.StatusOK, gin.H{"payload": gin.H{"webhook": serializeWebhookSubscription(*subscription)}})
}
// Update modifies a webhook subscription.
@@ -94,29 +96,26 @@ func (h *WebhookSubscriptionHandler) Update(c *gin.Context) {
response.AbortWithStatusError(c, http.StatusServiceUnavailable, response.ErrInternal, "Webhook subscription service not available")
return
}
id, err := parseUintParam(c, "id")
accountID := getAccountID(c)
id, err := parseUintAnyParam(c, "webhook_id", "id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "Invalid webhook subscription ID")
return
}
var req struct {
URL string `json:"url"`
Events []string `json:"events"`
Active bool `json:"active"`
}
if err := c.ShouldBindJSON(&req); err != nil {
var req service.WebhookSubscriptionMutation
if err := bindJSONWrappedOrRaw(c, "webhook", &req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "Invalid request body")
return
}
subscription, err := h.webhookSubscriptionService.UpdateSubscription(c.Request.Context(), id, req.URL, req.Events, req.Active)
subscription, err := h.webhookSubscriptionService.UpdateWebhook(c.Request.Context(), accountID, id, req)
if err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "Failed to update webhook subscription")
abortWebhookSubscriptionError(c, err)
return
}
response.OK(c, subscription)
c.JSON(http.StatusOK, gin.H{"payload": gin.H{"webhook": serializeWebhookSubscription(*subscription)}})
}
// Delete removes a webhook subscription.
@@ -126,18 +125,19 @@ func (h *WebhookSubscriptionHandler) Delete(c *gin.Context) {
response.AbortWithStatusError(c, http.StatusServiceUnavailable, response.ErrInternal, "Webhook subscription service not available")
return
}
id, err := parseUintParam(c, "id")
accountID := getAccountID(c)
id, err := parseUintAnyParam(c, "webhook_id", "id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "Invalid webhook subscription ID")
return
}
if err := h.webhookSubscriptionService.DeleteSubscription(c.Request.Context(), id); err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "Failed to delete webhook subscription")
if err := h.webhookSubscriptionService.DeleteWebhook(c.Request.Context(), accountID, id); err != nil {
abortWebhookSubscriptionError(c, err)
return
}
response.NoContent(c)
c.Status(http.StatusOK)
}
// ListDeliveries returns recent webhook delivery records for a subscription.
@@ -160,4 +160,41 @@ func (h *WebhookSubscriptionHandler) ListDeliveries(c *gin.Context) {
}
response.OK(c, gin.H{"deliveries": deliveries})
}
}
func abortWebhookSubscriptionError(c *gin.Context, err error) {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "webhook not found")
return
}
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, err.Error())
}
func serializeWebhookSubscriptions(subscriptions []model.WebhookSubscription) []gin.H {
items := make([]gin.H, 0, len(subscriptions))
for _, subscription := range subscriptions {
items = append(items, serializeWebhookSubscription(subscription))
}
return items
}
func serializeWebhookSubscription(subscription model.WebhookSubscription) gin.H {
var subscriptions []string
_ = json.Unmarshal(subscription.Events, &subscriptions)
payload := gin.H{
"id": subscription.ID,
"name": subscription.Name,
"url": subscription.URL,
"account_id": subscription.AccountID,
"subscriptions": subscriptions,
"secret": subscription.Secret,
}
if subscription.InboxID != nil && *subscription.InboxID != 0 {
inbox := gin.H{"id": *subscription.InboxID}
if subscription.Inbox.ID != 0 {
inbox["name"] = subscription.Inbox.Name
}
payload["inbox"] = inbox
}
return payload
}
@@ -2,6 +2,8 @@ package v1
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
@@ -31,7 +33,7 @@ func (s *WebhookSubscriptionHandlerTestSuite) SetupSuite() {
Logger: logger.Default.LogMode(logger.Silent),
})
s.Require().NoError(err)
s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.WebhookSubscription{}))
s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WebhookSubscription{}))
s.db = db
repo := repository.NewWebhookSubscriptionRepo(db)
@@ -55,21 +57,98 @@ func TestWebhookSubscriptionHandlerSuite(t *testing.T) {
func (s *WebhookSubscriptionHandlerTestSuite) TestList_Success() {
r := gin.New()
r.GET("/api/v1/accounts/:account_id/webhooks/:webhook_id/subscriptions", s.handler.List)
r.GET("/api/v1/accounts/:account_id/webhooks", s.handler.List)
_, err := s.handler.webhookSubscriptionService.CreateWebhook(context.Background(), s.account.ID, service.WebhookSubscriptionMutation{
Name: "List hook",
URL: "https://example.com/list-hook",
Subscriptions: []string{"message_created"},
})
s.Require().NoError(err)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/webhooks/1/subscriptions", s.account.ID), nil)
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/webhooks", s.account.ID), nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var body map[string]any
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &body))
payload := body["payload"].(map[string]any)
webhooks := payload["webhooks"].([]any)
s.NotEmpty(webhooks)
}
func (s *WebhookSubscriptionHandlerTestSuite) TestCreate_Success_ChatwootPayload() {
r := gin.New()
r.POST("/api/v1/accounts/:account_id/webhooks", s.handler.Create)
body := `{"webhook":{"name":"Created hook","url":"https://example.com/created-hook","subscriptions":["conversation_created","message_created"]}}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/webhooks", s.account.ID), bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var parsed map[string]any
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &parsed))
webhook := parsed["payload"].(map[string]any)["webhook"].(map[string]any)
s.Equal("Created hook", webhook["name"])
s.Equal("https://example.com/created-hook", webhook["url"])
s.NotEmpty(webhook["secret"])
s.Equal([]any{"conversation_created", "message_created"}, webhook["subscriptions"])
}
func (s *WebhookSubscriptionHandlerTestSuite) TestUpdate_Success_ChatwootPayload() {
created, err := s.handler.webhookSubscriptionService.CreateWebhook(context.Background(), s.account.ID, service.WebhookSubscriptionMutation{
Name: "Before",
URL: "https://example.com/update-before",
Subscriptions: []string{"message_created"},
})
s.Require().NoError(err)
r := gin.New()
r.PATCH("/api/v1/accounts/:account_id/webhooks/:webhook_id", s.handler.Update)
body := `{"webhook":{"name":"After","url":"https://example.com/update-after","subscriptions":["contact_created"]}}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/webhooks/%d", s.account.ID, created.ID), bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var parsed map[string]any
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &parsed))
webhook := parsed["payload"].(map[string]any)["webhook"].(map[string]any)
s.Equal("After", webhook["name"])
s.Equal("https://example.com/update-after", webhook["url"])
s.Equal([]any{"contact_created"}, webhook["subscriptions"])
}
func (s *WebhookSubscriptionHandlerTestSuite) TestDelete_Success_ReturnsEmptyOK() {
created, err := s.handler.webhookSubscriptionService.CreateWebhook(context.Background(), s.account.ID, service.WebhookSubscriptionMutation{
Name: "Delete",
URL: "https://example.com/delete-hook",
Subscriptions: []string{"message_created"},
})
s.Require().NoError(err)
r := gin.New()
r.DELETE("/api/v1/accounts/:account_id/webhooks/:webhook_id", s.handler.Delete)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/webhooks/%d", s.account.ID, created.ID), nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
s.Empty(w.Body.String())
}
func (s *WebhookSubscriptionHandlerTestSuite) TestCreate_BadRequest_EmptyBody() {
r := gin.New()
r.POST("/api/v1/accounts/:account_id/webhooks/:webhook_id/subscriptions", s.handler.Create)
r.POST("/api/v1/accounts/:account_id/webhooks", s.handler.Create)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/webhooks/1/subscriptions", s.account.ID), nil)
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/webhooks", s.account.ID), nil)
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -78,22 +157,21 @@ func (s *WebhookSubscriptionHandlerTestSuite) TestCreate_BadRequest_EmptyBody()
func (s *WebhookSubscriptionHandlerTestSuite) TestGet_BadRequest_InvalidID() {
r := gin.New()
r.GET("/api/v1/accounts/:account_id/webhooks/:webhook_id/subscriptions/:webhook_id", s.handler.Get)
r.GET("/api/v1/accounts/:account_id/webhooks/:webhook_id", s.handler.Get)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/webhooks/1/subscriptions/abc", s.account.ID), nil)
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/webhooks/abc", s.account.ID), nil)
r.ServeHTTP(w, req)
// Get returns 500 for invalid id (parseUintParam error → internal server error path)
assert.NotEqual(s.T(), http.StatusOK, w.Code)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *WebhookSubscriptionHandlerTestSuite) TestUpdate_BadRequest_InvalidID() {
r := gin.New()
r.PUT("/api/v1/accounts/:account_id/webhooks/:webhook_id/subscriptions/:webhook_id", s.handler.Update)
r.PATCH("/api/v1/accounts/:account_id/webhooks/:webhook_id", s.handler.Update)
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", fmt.Sprintf("/api/v1/accounts/%d/webhooks/1/subscriptions/abc", s.account.ID), bytes.NewBufferString(`{"url":"https://example.com"}`))
req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/webhooks/abc", s.account.ID), bytes.NewBufferString(`{"webhook":{"url":"https://example.com","subscriptions":["message_created"]}}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
@@ -102,10 +180,10 @@ func (s *WebhookSubscriptionHandlerTestSuite) TestUpdate_BadRequest_InvalidID()
func (s *WebhookSubscriptionHandlerTestSuite) TestDelete_BadRequest_InvalidID() {
r := gin.New()
r.DELETE("/api/v1/accounts/:account_id/webhooks/:webhook_id/subscriptions/:webhook_id", s.handler.Delete)
r.DELETE("/api/v1/accounts/:account_id/webhooks/:webhook_id", s.handler.Delete)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/webhooks/1/subscriptions/abc", s.account.ID), nil)
req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/webhooks/abc", s.account.ID), nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
@@ -120,4 +198,4 @@ func (s *WebhookSubscriptionHandlerTestSuite) TestListDeliveries_BadRequest_Inva
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
}
+28 -24
View File
@@ -10,20 +10,24 @@ import (
// WebhookSubscription represents an account-level webhook subscription for outgoing events.
// Reference: Chatwoot webhook integration + P2B M8 spec
type WebhookSubscription struct {
ID uint `gorm:"primaryKey" json:"id"`
AccountID uint `gorm:"not null;index" json:"account_id"`
URL string `gorm:"size:2048;not null" json:"url"`
Events json.RawMessage `gorm:"type:jsonb;not null" json:"events"` // JSON array of event types, e.g. ["conversation_created","message_created"]
Secret string `gorm:"size:128;not null" json:"secret,omitempty"` // HMAC-SHA256 signing secret
Active bool `gorm:"default:true" json:"active"`
VerifiedAt *time.Time `json:"verified_at,omitempty"`
LastDeliveryStatus string `gorm:"size:50" json:"last_delivery_status,omitempty"` // success/failed/pending
LastDeliveryAt *time.Time `json:"last_delivery_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
ID uint `gorm:"primaryKey" json:"id"`
AccountID uint `gorm:"not null;index" json:"account_id"`
InboxID *uint `gorm:"index" json:"inbox_id,omitempty"`
Name string `gorm:"size:255" json:"name,omitempty"`
URL string `gorm:"size:2048;not null" json:"url"`
Events json.RawMessage `gorm:"type:jsonb;not null" json:"subscriptions"` // JSON array of event types, e.g. ["conversation_created","message_created"]
Secret string `gorm:"size:128;not null" json:"secret,omitempty"` // HMAC-SHA256 signing secret
WebhookType int `gorm:"default:0" json:"webhook_type,omitempty"`
Active bool `gorm:"default:true" json:"active"`
VerifiedAt *time.Time `json:"verified_at,omitempty"`
LastDeliveryStatus string `gorm:"size:50" json:"last_delivery_status,omitempty"` // success/failed/pending
LastDeliveryAt *time.Time `json:"last_delivery_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"`
Inbox Inbox `gorm:"foreignKey:InboxID" json:"inbox,omitempty"`
}
func (WebhookSubscription) TableName() string { return "webhook_subscriptions" }
@@ -54,19 +58,19 @@ func (s *WebhookSubscription) IsEventSubscribed(eventType string) bool {
// WebhookDelivery represents a single webhook delivery attempt.
// Reference: Chatwoot webhook delivery tracking + P2B M8 spec
type WebhookDelivery struct {
ID uint `gorm:"primaryKey" json:"id"`
SubscriptionID uint `gorm:"not null;index" json:"subscription_id"`
EventType string `gorm:"size:100;not null;index" json:"event_type"`
Payload json.RawMessage `gorm:"type:jsonb" json:"payload"`
ResponseCode int `json:"response_code,omitempty"`
ResponseBody string `gorm:"size:4096" json:"response_body,omitempty"`
Status string `gorm:"size:50;not null;index" json:"status"` // success/failed/retrying
Attempts int `gorm:"default:0" json:"attempts"`
NextAttemptAt *time.Time `json:"next_attempt_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
ID uint `gorm:"primaryKey" json:"id"`
SubscriptionID uint `gorm:"not null;index" json:"subscription_id"`
EventType string `gorm:"size:100;not null;index" json:"event_type"`
Payload json.RawMessage `gorm:"type:jsonb" json:"payload"`
ResponseCode int `json:"response_code,omitempty"`
ResponseBody string `gorm:"size:4096" json:"response_body,omitempty"`
Status string `gorm:"size:50;not null;index" json:"status"` // success/failed/retrying
Attempts int `gorm:"default:0" json:"attempts"`
NextAttemptAt *time.Time `json:"next_attempt_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Subscription WebhookSubscription `gorm:"foreignKey:SubscriptionID" json:"subscription,omitempty"`
}
func (WebhookDelivery) TableName() string { return "webhook_deliveries" }
func (WebhookDelivery) TableName() string { return "webhook_deliveries" }
@@ -2,6 +2,7 @@ package repository
import (
"context"
"encoding/json"
"gorm.io/gorm"
@@ -22,7 +23,32 @@ func NewWebhookSubscriptionRepo(db *gorm.DB) *WebhookSubscriptionRepo {
// FindByID retrieves a webhook subscription by primary key.
func (r *WebhookSubscriptionRepo) FindByID(ctx context.Context, id uint) (*model.WebhookSubscription, error) {
var s model.WebhookSubscription
err := r.db.WithContext(ctx).First(&s, id).Error
err := r.db.WithContext(ctx).Preload("Inbox").First(&s, id).Error
if err != nil {
return nil, err
}
return &s, nil
}
// FindByAccountAndID retrieves a webhook subscription scoped to an account.
func (r *WebhookSubscriptionRepo) FindByAccountAndID(ctx context.Context, accountID, id uint) (*model.WebhookSubscription, error) {
var s model.WebhookSubscription
err := r.db.WithContext(ctx).
Preload("Inbox").
Where("account_id = ?", accountID).
First(&s, id).Error
if err != nil {
return nil, err
}
return &s, nil
}
// FindByAccountAndURL retrieves an active webhook by account and URL.
func (r *WebhookSubscriptionRepo) FindByAccountAndURL(ctx context.Context, accountID uint, url string) (*model.WebhookSubscription, error) {
var s model.WebhookSubscription
err := r.db.WithContext(ctx).
Where("account_id = ? AND url = ? AND active = ?", accountID, url, true).
First(&s).Error
if err != nil {
return nil, err
}
@@ -37,7 +63,11 @@ func (r *WebhookSubscriptionRepo) GetByID(ctx context.Context, id uint) (*model.
// ListByAccount retrieves all webhook subscriptions for an account.
func (r *WebhookSubscriptionRepo) ListByAccount(ctx context.Context, accountID uint) ([]model.WebhookSubscription, error) {
var subs []model.WebhookSubscription
err := r.db.WithContext(ctx).Where("account_id = ? AND active = ?", accountID, true).Find(&subs).Error
err := r.db.WithContext(ctx).
Preload("Inbox").
Where("account_id = ? AND active = ?", accountID, true).
Order("id ASC").
Find(&subs).Error
return subs, err
}
@@ -45,20 +75,33 @@ func (r *WebhookSubscriptionRepo) ListByAccount(ctx context.Context, accountID u
func (r *WebhookSubscriptionRepo) ListActiveByAccount(ctx context.Context, accountID uint) ([]model.WebhookSubscription, error) {
var subs []model.WebhookSubscription
err := r.db.WithContext(ctx).
Preload("Inbox").
Where("account_id = ? AND active = ?", accountID, true).
Order("id ASC").
Find(&subs).Error
return subs, err
}
// ListByAccountAndEvent retrieves webhook subscriptions that include a specific event type.
func (r *WebhookSubscriptionRepo) ListByAccountAndEvent(ctx context.Context, accountID uint, eventType string) ([]model.WebhookSubscription, error) {
var subs []model.WebhookSubscription
// Use JSON containment operator for PostgreSQL: events @> '["event_type"]'
err := r.db.WithContext(ctx).
Where("account_id = ? AND active = ?", accountID, true).
Where("events @> ?", eventType).
Find(&subs).Error
return subs, err
subs, err := r.ListActiveByAccount(ctx, accountID)
if err != nil {
return nil, err
}
filtered := make([]model.WebhookSubscription, 0, len(subs))
for _, sub := range subs {
var events []string
if err := json.Unmarshal(sub.Events, &events); err != nil {
continue
}
for _, event := range events {
if event == eventType {
filtered = append(filtered, sub)
break
}
}
}
return filtered, nil
}
// Create inserts a new webhook subscription.
@@ -97,4 +140,4 @@ func (r *WebhookSubscriptionRepo) ListDeliveriesBySubscription(ctx context.Conte
Limit(limit).
Find(&deliveries).Error
return deliveries, err
}
}
+1
View File
@@ -554,6 +554,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
g.POST("/accounts/:account_id/webhooks", h.WebhookSubscription.Create)
g.GET("/accounts/:account_id/webhooks/:webhook_id", h.WebhookSubscription.Get)
g.PUT("/accounts/:account_id/webhooks/:webhook_id", h.WebhookSubscription.Update)
g.PATCH("/accounts/:account_id/webhooks/:webhook_id", h.WebhookSubscription.Update)
g.DELETE("/accounts/:account_id/webhooks/:webhook_id", h.WebhookSubscription.Delete)
// Account routes — scoped with AccountScope middleware (ref: Chatwoot namespace :accounts)
@@ -4,10 +4,13 @@ import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"net/url"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"gorm.io/gorm"
)
// WebhookSubscriptionService provides business logic for managing webhook subscriptions.
@@ -16,6 +19,29 @@ type WebhookSubscriptionService struct {
webhookSubRepo *repository.WebhookSubscriptionRepo
}
var allowedWebhookSubscriptions = map[string]struct{}{
"conversation_status_changed": {},
"conversation_updated": {},
"conversation_created": {},
"contact_created": {},
"contact_updated": {},
"message_created": {},
"message_updated": {},
"webwidget_triggered": {},
"inbox_created": {},
"inbox_updated": {},
"conversation_typing_on": {},
"conversation_typing_off": {},
}
// WebhookSubscriptionMutation is the Chatwoot account webhook create/update payload.
type WebhookSubscriptionMutation struct {
InboxID *uint `json:"inbox_id"`
Name string `json:"name"`
URL string `json:"url"`
Subscriptions []string `json:"subscriptions"`
}
// NewWebhookSubscriptionService creates a new WebhookSubscription service with required dependencies.
func NewWebhookSubscriptionService(webhookSubRepo *repository.WebhookSubscriptionRepo) *WebhookSubscriptionService {
return &WebhookSubscriptionService{
@@ -29,8 +55,23 @@ func (s *WebhookSubscriptionService) ListSubscriptions(ctx context.Context, acco
}
// CreateSubscription creates a new webhook subscription with a generated signing secret.
func (s *WebhookSubscriptionService) CreateSubscription(ctx context.Context, accountID uint, url string, events []string) (*model.WebhookSubscription, error) {
eventsJSON, err := json.Marshal(events)
func (s *WebhookSubscriptionService) CreateSubscription(ctx context.Context, accountID uint, webhookURL string, events []string) (*model.WebhookSubscription, error) {
req := WebhookSubscriptionMutation{URL: webhookURL, Subscriptions: events}
return s.CreateWebhook(ctx, accountID, req)
}
// CreateWebhook creates a Chatwoot-compatible account webhook.
func (s *WebhookSubscriptionService) CreateWebhook(ctx context.Context, accountID uint, req WebhookSubscriptionMutation) (*model.WebhookSubscription, error) {
if err := validateWebhookMutation(req, true); err != nil {
return nil, err
}
if existing, err := s.webhookSubRepo.FindByAccountAndURL(ctx, accountID, req.URL); err == nil && existing.ID != 0 {
return nil, fmt.Errorf("url has already been taken")
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
eventsJSON, err := json.Marshal(req.Subscriptions)
if err != nil {
return nil, fmt.Errorf("marshal events: %w", err)
}
@@ -43,7 +84,9 @@ func (s *WebhookSubscriptionService) CreateSubscription(ctx context.Context, acc
sub := &model.WebhookSubscription{
AccountID: accountID,
URL: url,
InboxID: req.InboxID,
Name: req.Name,
URL: req.URL,
Events: eventsJSON,
Secret: secret,
Active: true,
@@ -79,6 +122,51 @@ func (s *WebhookSubscriptionService) UpdateSubscription(ctx context.Context, id
return sub, nil
}
// UpdateWebhook updates a Chatwoot-compatible account webhook scoped to the account.
func (s *WebhookSubscriptionService) UpdateWebhook(ctx context.Context, accountID, id uint, req WebhookSubscriptionMutation) (*model.WebhookSubscription, error) {
if err := validateWebhookMutation(req, false); err != nil {
return nil, err
}
sub, err := s.webhookSubRepo.FindByAccountAndID(ctx, accountID, id)
if err != nil {
return nil, fmt.Errorf("find subscription: %w", err)
}
if req.URL != "" && req.URL != sub.URL {
if existing, err := s.webhookSubRepo.FindByAccountAndURL(ctx, accountID, req.URL); err == nil && existing.ID != sub.ID {
return nil, fmt.Errorf("url has already been taken")
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
sub.URL = req.URL
}
sub.Name = req.Name
sub.InboxID = req.InboxID
eventsJSON, err := json.Marshal(req.Subscriptions)
if err != nil {
return nil, fmt.Errorf("marshal events: %w", err)
}
sub.Events = eventsJSON
sub.Active = true
if err := s.webhookSubRepo.Update(ctx, sub); err != nil {
return nil, err
}
return s.webhookSubRepo.FindByAccountAndID(ctx, accountID, id)
}
// DeleteWebhook deletes a Chatwoot-compatible account webhook scoped to the account.
func (s *WebhookSubscriptionService) DeleteWebhook(ctx context.Context, accountID, id uint) error {
if _, err := s.webhookSubRepo.FindByAccountAndID(ctx, accountID, id); err != nil {
return fmt.Errorf("find subscription: %w", err)
}
return s.webhookSubRepo.Delete(ctx, id)
}
// GetWebhook retrieves a Chatwoot-compatible account webhook scoped to the account.
func (s *WebhookSubscriptionService) GetWebhook(ctx context.Context, accountID, id uint) (*model.WebhookSubscription, error) {
return s.webhookSubRepo.FindByAccountAndID(ctx, accountID, id)
}
// DeleteSubscription soft-deletes a webhook subscription.
func (s *WebhookSubscriptionService) DeleteSubscription(ctx context.Context, id uint) error {
return s.webhookSubRepo.Delete(ctx, id)
@@ -101,4 +189,27 @@ func generateWebhookSecret() (string, error) {
return "", err
}
return fmt.Sprintf("%x", b), nil
}
}
func validateWebhookMutation(req WebhookSubscriptionMutation, requireURL bool) error {
if requireURL || req.URL != "" {
u, err := url.ParseRequestURI(req.URL)
if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fmt.Errorf("url is invalid")
}
}
if len(req.Subscriptions) == 0 {
return fmt.Errorf("subscriptions is invalid")
}
seen := map[string]struct{}{}
for _, subscription := range req.Subscriptions {
if _, ok := allowedWebhookSubscriptions[subscription]; !ok {
return fmt.Errorf("subscriptions is invalid")
}
if _, ok := seen[subscription]; ok {
return fmt.Errorf("subscriptions is invalid")
}
seen[subscription] = struct{}{}
}
return nil
}
@@ -0,0 +1,74 @@
package service
import (
"context"
"testing"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestWebhookSubscriptionServiceChatwootParity(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WebhookSubscription{}, &model.WebhookDelivery{}))
account := model.Account{Name: "webhook-service-account"}
require.NoError(t, db.Create(&account).Error)
inbox := model.Inbox{AccountID: account.ID, Name: "Support"}
require.NoError(t, db.Create(&inbox).Error)
svc := NewWebhookSubscriptionService(repository.NewWebhookSubscriptionRepo(db))
created, err := svc.CreateWebhook(context.Background(), account.ID, WebhookSubscriptionMutation{
InboxID: &inbox.ID,
Name: "Service hook",
URL: "https://example.com/service-hook",
Subscriptions: []string{"message_created", "conversation_updated"},
})
require.NoError(t, err)
require.NotEmpty(t, created.Secret)
require.Equal(t, "Service hook", created.Name)
require.True(t, created.IsEventSubscribed("message_created"))
updated, err := svc.UpdateWebhook(context.Background(), account.ID, created.ID, WebhookSubscriptionMutation{
Name: "Updated service hook",
URL: "https://example.com/service-hook-updated",
Subscriptions: []string{"contact_created"},
})
require.NoError(t, err)
require.Equal(t, "Updated service hook", updated.Name)
require.True(t, updated.IsEventSubscribed("contact_created"))
require.False(t, updated.IsEventSubscribed("message_created"))
matches, err := repository.NewWebhookSubscriptionRepo(db).ListByAccountAndEvent(context.Background(), account.ID, "contact_created")
require.NoError(t, err)
require.Len(t, matches, 1)
otherAccount := model.Account{Name: "other-webhook-service-account"}
require.NoError(t, db.Create(&otherAccount).Error)
require.Error(t, svc.DeleteWebhook(context.Background(), otherAccount.ID, created.ID))
require.NoError(t, svc.DeleteWebhook(context.Background(), account.ID, created.ID))
}
func TestWebhookSubscriptionServiceValidation(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.WebhookSubscription{}))
svc := NewWebhookSubscriptionService(repository.NewWebhookSubscriptionRepo(db))
_, err = svc.CreateWebhook(context.Background(), 1, WebhookSubscriptionMutation{
URL: "ftp://example.com/hook",
Subscriptions: []string{"message_created"},
})
require.Error(t, err)
_, err = svc.CreateWebhook(context.Background(), 1, WebhookSubscriptionMutation{
URL: "https://example.com/hook",
Subscriptions: []string{"not_allowed"},
})
require.Error(t, err)
}
@@ -0,0 +1,7 @@
DROP INDEX IF EXISTS idx_webhook_subscriptions_account_url;
DROP INDEX IF EXISTS idx_webhook_subscriptions_inbox_id;
ALTER TABLE webhook_subscriptions
DROP COLUMN IF EXISTS webhook_type,
DROP COLUMN IF EXISTS name,
DROP COLUMN IF EXISTS inbox_id;
@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS webhook_subscriptions (
id SERIAL PRIMARY KEY,
account_id INTEGER NOT NULL,
inbox_id INTEGER,
name VARCHAR(255),
url VARCHAR(2048) NOT NULL,
events JSONB NOT NULL DEFAULT '[]',
secret VARCHAR(128) NOT NULL,
webhook_type INTEGER DEFAULT 0,
active BOOLEAN DEFAULT TRUE,
verified_at TIMESTAMP WITH TIME ZONE,
last_delivery_status VARCHAR(50),
last_delivery_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE
);
ALTER TABLE webhook_subscriptions
ADD COLUMN IF NOT EXISTS inbox_id INTEGER,
ADD COLUMN IF NOT EXISTS name VARCHAR(255),
ADD COLUMN IF NOT EXISTS webhook_type INTEGER DEFAULT 0;
UPDATE webhook_subscriptions SET active = TRUE WHERE active IS NULL;
CREATE INDEX IF NOT EXISTS idx_webhook_subscriptions_account_id ON webhook_subscriptions(account_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_webhook_subscriptions_inbox_id ON webhook_subscriptions(inbox_id) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_webhook_subscriptions_account_url ON webhook_subscriptions(account_id, url) WHERE deleted_at IS NULL AND active = TRUE;