feat(integrations): align app hook payloads

This commit is contained in:
2026-06-06 06:46:36 +08:00
parent 02ea135e0c
commit d241925b6a
12 changed files with 504 additions and 132 deletions
+8
View File
@@ -177,6 +177,14 @@ var criticalRoutes = []route{
{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/integrations/apps", Controller: "api/v1/accounts/integrations/apps#index", Source: "routes.rb:345"},
{Method: "GET", Path: "/api/v1/accounts/:account_id/integrations/apps/:id", Controller: "api/v1/accounts/integrations/apps#show", Source: "routes.rb:345"},
{Method: "GET", Path: "/api/v1/accounts/:account_id/integrations/hooks/:id", Controller: "api/v1/accounts/integrations/hooks#show", Source: "routes.rb:346"},
{Method: "POST", Path: "/api/v1/accounts/:account_id/integrations/hooks", Controller: "api/v1/accounts/integrations/hooks#create", Source: "routes.rb:346"},
{Method: "PUT", Path: "/api/v1/accounts/:account_id/integrations/hooks/:id", Controller: "api/v1/accounts/integrations/hooks#update", Source: "routes.rb:346"},
{Method: "PATCH", Path: "/api/v1/accounts/:account_id/integrations/hooks/:id", Controller: "api/v1/accounts/integrations/hooks#update", Source: "routes.rb:346"},
{Method: "DELETE", Path: "/api/v1/accounts/:account_id/integrations/hooks/:id", Controller: "api/v1/accounts/integrations/hooks#destroy", Source: "routes.rb:346"},
{Method: "POST", Path: "/api/v1/accounts/:account_id/integrations/hooks/:id/process_event", Controller: "api/v1/accounts/integrations/hooks#process_event", Source: "routes.rb:348"},
{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"},
+15 -11
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(webhooks): align account payloads`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(webhooks): align account payloads`.
- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(integrations): align app hook payloads`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(integrations): align app hook 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.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.
- Worktree status at this implementation checkpoint: P3.11 account integrations apps/hooks API parity is now implemented for the reused dashboard integrations settings flows. Account `/integrations/apps` and `/integrations/hooks` now track Chatwoot `routes.rb:345-348`, return Chatwoot-style raw app/hook payloads, expose frontend no-trailing-slash create/list routes plus hook `PATCH`, accept raw frontend hook bodies and nested `{ hook: ... }`, persist `app_id` and `reference_id`, seed the reference app catalog additions, scope hook show/update/delete through current account, serialize hook `status` as boolean and `hook_type` as account/inbox, include app `hooks`, `settings_form_schema`, and `visible_properties`, and return empty `200 OK` delete. P3.10 account webhook API, 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: 865` after adding account webhook PATCH update route.
- Route dump succeeds with `TOTAL: 869` after adding account integration app/hook no-trailing-slash and PATCH routes.
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
- 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.
- Tracked frontend-critical route audit covers 328 Chatwoot routes: 321 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,14 +126,15 @@ 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 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. |
| 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 328-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`, account webhook routes from `routes.rb:342`, and account integration app/hook routes from `routes.rb:345-348` 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, integration app/hook payload 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 | 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. |
| 5 | P3.11 account integrations apps/hooks API | Implemented for reused dashboard integrations settings with `{ payload: [...] }` app index, raw app/show and hook mutation payloads, raw or nested create/update bodies, account-scoped hook show/update/delete, persisted `app_id/reference_id`, reference app catalog seed additions, app hook/settings schema serializers, hook boolean `status`, account/inbox `hook_type`, and empty `200 OK` delete. | Keep in Review; reopen if live settings smoke exposes app feature-flag activation, app-specific credential validation, or provider setup side effects beyond this generic hooks boundary. | Focused integration hook handler/service tests, migration/router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
| 6 | 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. |
| 7 | 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. |
| 8 | 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. |
| 9 | 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
@@ -154,6 +155,7 @@ These rows are the executable development plan from this point forward. A checkp
| 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. |
| P3.11 account integration apps/hooks API parity | `internal/router/router.go`, `internal/handler/api/v1/integration_hook_handler.go`, `internal/service/integration_hook_service.go`, `internal/repository/integration_hook_repo.go`, `internal/model/integration_hook.go`, integration hook handler/service tests, migrations, `cmd/route_parity` | `reference/chatwoot/config/routes.rb:345-348`, `reference/chatwoot/app/controllers/api/v1/accounts/integrations/apps_controller.rb`, `app/controllers/api/v1/accounts/integrations/hooks_controller.rb`, `app/views/api/v1/accounts/integrations/apps/*.jbuilder`, `app/views/api/v1/accounts/integrations/hooks/*.jbuilder`, `app/views/api/v1/models/_app.json.jbuilder`, `app/views/api/v1/models/_hook.json.jbuilder`, `app/models/integrations/app.rb`, `app/models/integrations/hook.rb`, `config/integration/apps.yml`, dashboard `api/integrations.js`, `store/modules/integrations.js`, settings integrations components | Account integration apps/hooks now match the reused dashboard integration settings contract: app index returns `{ payload: [...] }`; app show returns a raw app object; hooks create/show/update return raw hook objects; delete returns empty `200 OK`; frontend no-trailing-slash app/hook routes and hook `PATCH` update are registered and tracked; raw frontend hook bodies and nested `{ hook: ... }` are accepted; hooks persist `app_id`/`reference_id`; show/update/delete are account-scoped; hook serializers expose `app_id`, boolean `status`, `account_id`, account/inbox `hook_type`, `settings`, `reference_id`, and optional inbox; app serializers expose `hooks`, `action`, `button`, `allow_multiple_hooks`, `settings_form_schema`, and `visible_properties`; migration `000030` seeds the additional reference app catalog rows. | Review by `feat(integrations): align app hook payloads`; focused integration hook handler/service tests, `go test ./cmd/migrate -count=1`, `go test ./cmd/route_parity -count=1`, router focused test, route dump/parity regenerated to `TOTAL: 869` and `321 exact, 7 parameter-compatible, 0 missing out of 328`, 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. |
@@ -193,6 +195,7 @@ This ledger records the committed parity checkpoints that future slices should b
| Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- |
| `feat(integrations): align app hook payloads` | Advances P3.11 account integrations apps/hooks parity by matching Chatwoot `Integrations::AppsController`, `Integrations::HooksController`, app/hook Jbuilder views, `Integrations::App`, `Integrations::Hook`, `config/integration/apps.yml`, and the reused dashboard integrations API/store/settings components. GoChat now tracks `routes.rb:345-348`, registers frontend no-trailing-slash app/hook routes plus hook `PATCH`, returns Chatwoot `{ payload: [...] }` app index and raw app/hook objects, accepts raw frontend hook bodies plus nested `{ hook: ... }`, persists `app_id` and `reference_id`, scopes hook show/update/delete by account, serializes boolean hook `status`, account/inbox `hook_type`, settings, inbox, app hooks, settings schemas, visible properties, and seeds the additional reference app catalog rows. | `go test ./internal/handler/api/v1 -run IntegrationHook -count=1`; `go test ./internal/service -run IntegrationHook -count=1`; `go test ./cmd/migrate -count=1`; `go test ./cmd/route_parity -count=1`; `go test ./internal/router -run Router -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: 869`; tracked route parity is `321 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 328`. | P3.11 moves to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
| `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. |
@@ -1647,7 +1650,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. |
| P3.10 | Account webhooks/integration settings | Account webhook CRUD, integration app/hook 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. P3.11 integration apps/hooks now also match the reused dashboard integrations list/create/delete path with raw app/hook serializers, `app_id/reference_id`, settings schemas, account-scoped hook mutations, and empty delete. |
Serializer parity work plan:
@@ -2155,6 +2158,7 @@ Verification milestone gates:
## Progress Log
- 2026-06-06: P3.11 account integrations apps/hooks checkpoint prepared as `feat(integrations): align app hook payloads`; audited Chatwoot `routes.rb:345-348`, `Integrations::AppsController`, `Integrations::HooksController`, app/hook Jbuilder views, `Integrations::App`, `Integrations::Hook`, `config/integration/apps.yml`, and reused dashboard `api/integrations.js`, `store/modules/integrations.js`, and settings integration components. Account integration apps/hooks now return Chatwoot `{ payload: [...] }` app index payloads and raw app/hook objects, accept raw frontend hook bodies plus nested `{ hook: ... }`, register frontend no-trailing-slash app/hook routes and hook `PATCH`, persist `app_id/reference_id`, scope hook show/update/delete by account, serialize boolean `status`, account/inbox `hook_type`, `settings`, `inbox`, app `hooks`, settings schemas, and visible properties, seed additional reference app catalog rows, and return empty `200 OK` delete. Focused integration hook handler/service tests, migration test, route parity test, router focused test, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed; route dump is `TOTAL: 869` and tracked route parity is `321 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 328`.
- 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`.
+5 -1
View File
@@ -268,8 +268,10 @@ GET /api/v1/accounts/:account_id/instagram/callback
GET /api/v1/accounts/:account_id/instagram/oauth
GET /api/v1/accounts/:account_id/instagram/webhooks
GET /api/v1/accounts/:account_id/instagram_channels/
GET /api/v1/accounts/:account_id/integrations/apps
GET /api/v1/accounts/:account_id/integrations/apps/
GET /api/v1/accounts/:account_id/integrations/apps/:id
GET /api/v1/accounts/:account_id/integrations/hooks
GET /api/v1/accounts/:account_id/integrations/hooks/
GET /api/v1/accounts/:account_id/integrations/hooks/:id
GET /api/v1/accounts/:account_id/integrations/linear/linked_issues
@@ -484,6 +486,7 @@ PATCH /api/v1/accounts/:account_id/inboxes/:inbox_id/members/:user_id
PATCH /api/v1/accounts/:account_id/inboxes/:inbox_id/members/update_multiple
PATCH /api/v1/accounts/:account_id/inboxes/:inbox_id/tiktok_channels/:tt_id
PATCH /api/v1/accounts/:account_id/inboxes/:inbox_id/twilio_sms_channels/:tw_id
PATCH /api/v1/accounts/:account_id/integrations/hooks/:id
PATCH /api/v1/accounts/:account_id/integrations/slack/
PATCH /api/v1/accounts/:account_id/notification_settings
PATCH /api/v1/accounts/:account_id/notification_settings/
@@ -641,6 +644,7 @@ POST /api/v1/accounts/:account_id/inboxes/:inbox_id/sync_templates
POST /api/v1/accounts/:account_id/inboxes/web_widget
POST /api/v1/accounts/:account_id/instagram/webhooks
POST /api/v1/accounts/:account_id/instagram_channels/
POST /api/v1/accounts/:account_id/integrations/hooks
POST /api/v1/accounts/:account_id/integrations/hooks/
POST /api/v1/accounts/:account_id/integrations/hooks/:id/process_event
POST /api/v1/accounts/:account_id/integrations/linear/create_issue
@@ -863,4 +867,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: 865
TOTAL: 869
+9 -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: 313 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 320 tracked critical routes.
Summary: 321 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 328 tracked critical routes.
## Missing Critical Routes
@@ -68,6 +68,7 @@ These routes exist with equivalent method and path shape but different parameter
| DELETE | `/api/v1/accounts/:account_id/inboxes/:inbox_id` | `/api/v1/accounts/:account_id/inboxes/:inbox_id` | `api/v1/accounts/inboxes#destroy` | `routes.rb:252` | exact |
| DELETE | `/api/v1/accounts/:account_id/inboxes/:inbox_id/assignment_policy/` | `/api/v1/accounts/:account_id/inboxes/:inbox_id/assignment_policy/` | `api/v1/accounts/inboxes/assignment_policy#destroy` | `routes.rb:311` | exact |
| DELETE | `/api/v1/accounts/:account_id/inboxes/:inbox_id/avatar` | `/api/v1/accounts/:account_id/inboxes/:inbox_id/avatar` | `api/v1/accounts/inboxes#avatar` | `routes.rb:257` | exact |
| DELETE | `/api/v1/accounts/:account_id/integrations/hooks/:id` | `/api/v1/accounts/:account_id/integrations/hooks/:id` | `api/v1/accounts/integrations/hooks#destroy` | `routes.rb:346` | exact |
| DELETE | `/api/v1/accounts/:account_id/labels/:tag_id` | `/api/v1/accounts/:account_id/labels/:tag_id` | `api/v1/accounts/labels#destroy` | `routes.rb:281` | exact |
| DELETE | `/api/v1/accounts/:account_id/macros/:macro_id` | `/api/v1/accounts/:account_id/macros/:macro_id` | `api/v1/accounts/macros#destroy` | `routes.rb:118` | exact |
| DELETE | `/api/v1/accounts/:account_id/notifications/:notification_id` | `/api/v1/accounts/:account_id/notifications/:notification_id` | `api/v1/accounts/notifications#destroy` | `routes.rb:283` | exact |
@@ -151,6 +152,9 @@ These routes exist with equivalent method and path shape but different parameter
| GET | `/api/v1/accounts/:account_id/inboxes/:inbox_id/assignment_policy/` | `/api/v1/accounts/:account_id/inboxes/:inbox_id/assignment_policy/` | `api/v1/accounts/inboxes/assignment_policy#show` | `routes.rb:311` | exact |
| GET | `/api/v1/accounts/:account_id/inboxes/:inbox_id/campaigns` | `/api/v1/accounts/:account_id/inboxes/:inbox_id/campaigns` | `api/v1/accounts/inboxes#campaigns` | `routes.rb:254` | exact |
| GET | `/api/v1/accounts/:account_id/inboxes/:inbox_id/health` | `/api/v1/accounts/:account_id/inboxes/:inbox_id/health` | `api/v1/accounts/inboxes#health` | `routes.rb:259` | exact |
| GET | `/api/v1/accounts/:account_id/integrations/apps` | `/api/v1/accounts/:account_id/integrations/apps` | `api/v1/accounts/integrations/apps#index` | `routes.rb:345` | exact |
| GET | `/api/v1/accounts/:account_id/integrations/apps/:id` | `/api/v1/accounts/:account_id/integrations/apps/:id` | `api/v1/accounts/integrations/apps#show` | `routes.rb:345` | exact |
| GET | `/api/v1/accounts/:account_id/integrations/hooks/:id` | `/api/v1/accounts/:account_id/integrations/hooks/:id` | `api/v1/accounts/integrations/hooks#show` | `routes.rb:346` | exact |
| GET | `/api/v1/accounts/:account_id/labels/` | `/api/v1/accounts/:account_id/labels/` | `api/v1/accounts/labels#index` | `routes.rb:281` | exact |
| GET | `/api/v1/accounts/:account_id/labels/:tag_id` | `/api/v1/accounts/:account_id/labels/:tag_id` | `api/v1/accounts/labels#show` | `routes.rb:281` | exact |
| GET | `/api/v1/accounts/:account_id/macros/` | `/api/v1/accounts/:account_id/macros/` | `api/v1/accounts/macros#index` | `routes.rb:118` | exact |
@@ -211,6 +215,7 @@ These routes exist with equivalent method and path shape but different parameter
| PATCH | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id` | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id` | `api/v1/accounts/agent_bots#update` | `routes.rb:94` | exact |
| PATCH | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `api/v1/accounts/agent_capacity_policies#update` | `routes.rb:123` | exact |
| PATCH | `/api/v1/accounts/:account_id/inbox_members/` | `/api/v1/accounts/:account_id/inbox_members/` | `api/v1/accounts/inbox_members#update` | `routes.rb:278` | exact |
| PATCH | `/api/v1/accounts/:account_id/integrations/hooks/:id` | `/api/v1/accounts/:account_id/integrations/hooks/:id` | `api/v1/accounts/integrations/hooks#update` | `routes.rb:346` | exact |
| PATCH | `/api/v1/accounts/:account_id/portals/:portal_id` | `/api/v1/accounts/:account_id/portals/:portal_id` | `api/v1/accounts/portals#update` | `routes.rb:385` | exact |
| PATCH | `/api/v1/accounts/:account_id/portals/:portal_id/archive` | `/api/v1/accounts/:account_id/portals/:portal_id/archive` | `api/v1/accounts/portals#archive` | `routes.rb:387` | exact |
| PATCH | `/api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id` | `/api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id` | `api/v1/accounts/articles#update` | `routes.rb:403` | exact |
@@ -281,6 +286,8 @@ These routes exist with equivalent method and path shape but different parameter
| POST | `/api/v1/accounts/:account_id/inboxes/:inbox_id/reset_secret` | `/api/v1/accounts/:account_id/inboxes/:inbox_id/reset_secret` | `api/v1/accounts/inboxes#reset_secret` | `routes.rb:262` | exact |
| POST | `/api/v1/accounts/:account_id/inboxes/:inbox_id/set_agent_bot` | `/api/v1/accounts/:account_id/inboxes/:inbox_id/set_agent_bot` | `api/v1/accounts/inboxes#set_agent_bot` | `routes.rb:256` | exact |
| POST | `/api/v1/accounts/:account_id/inboxes/:inbox_id/sync_templates` | `/api/v1/accounts/:account_id/inboxes/:inbox_id/sync_templates` | `api/v1/accounts/inboxes#sync_templates` | `routes.rb:258` | exact |
| POST | `/api/v1/accounts/:account_id/integrations/hooks` | `/api/v1/accounts/:account_id/integrations/hooks` | `api/v1/accounts/integrations/hooks#create` | `routes.rb:346` | exact |
| POST | `/api/v1/accounts/:account_id/integrations/hooks/:id/process_event` | `/api/v1/accounts/:account_id/integrations/hooks/:id/process_event` | `api/v1/accounts/integrations/hooks#process_event` | `routes.rb:348` | exact |
| POST | `/api/v1/accounts/:account_id/labels/` | `/api/v1/accounts/:account_id/labels/` | `api/v1/accounts/labels#create` | `routes.rb:281` | exact |
| POST | `/api/v1/accounts/:account_id/macros/` | `/api/v1/accounts/:account_id/macros/` | `api/v1/accounts/macros#create` | `routes.rb:118` | exact |
| POST | `/api/v1/accounts/:account_id/macros/:macro_id/execute` | `/api/v1/accounts/:account_id/macros/:macro_id/execute` | `api/v1/accounts/macros#execute` | `routes.rb:119` | exact |
@@ -339,6 +346,7 @@ These routes exist with equivalent method and path shape but different parameter
| PUT | `/api/v1/accounts/:account_id/custom_attribute_definitions/:id` | `/api/v1/accounts/:account_id/custom_attribute_definitions/:id` | `api/v1/accounts/custom_attribute_definitions#update` | `routes.rb:250` | exact |
| PUT | `/api/v1/accounts/:account_id/custom_filters/:id` | `/api/v1/accounts/:account_id/custom_filters/:id` | `api/v1/accounts/custom_filters#update` | `routes.rb:251` | exact |
| PUT | `/api/v1/accounts/:account_id/inboxes/:inbox_id` | `/api/v1/accounts/:account_id/inboxes/:inbox_id` | `api/v1/accounts/inboxes#update` | `routes.rb:252` | exact |
| PUT | `/api/v1/accounts/:account_id/integrations/hooks/:id` | `/api/v1/accounts/:account_id/integrations/hooks/:id` | `api/v1/accounts/integrations/hooks#update` | `routes.rb:346` | exact |
| PUT | `/api/v1/accounts/:account_id/labels/:tag_id` | `/api/v1/accounts/:account_id/labels/:tag_id` | `api/v1/accounts/labels#update` | `routes.rb:281` | exact |
| PUT | `/api/v1/accounts/:account_id/macros/:macro_id` | `/api/v1/accounts/:account_id/macros/:macro_id` | `api/v1/accounts/macros#update` | `routes.rb:118` | exact |
| PUT | `/api/v1/accounts/:account_id/notification_settings/` | `/api/v1/accounts/:account_id/notification_settings/` | `api/v1/accounts/notification_settings#update` | `routes.rb:293` | exact |
@@ -1,11 +1,13 @@
package v1
import (
"encoding/json"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/service"
"github.com/gochat/gochat/pkg/pagination"
"github.com/gochat/gochat/pkg/response"
@@ -37,24 +39,19 @@ func (h *IntegrationHookHandler) ListApps(c *gin.Context) {
handleServiceError(c, err)
return
}
response.OK(c, apps)
accountID := getAccountID(c)
c.JSON(http.StatusOK, gin.H{"payload": h.serializeIntegrationApps(c, accountID, apps)})
}
// GetApp retrieves a single integration app by ID.
// GET /api/v1/accounts/:account_id/integrations/apps/:id
func (h *IntegrationHookHandler) GetApp(c *gin.Context) {
id, err := parseUintParam(c, "id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
return
}
app, svcErr := h.svc.GetApp(c.Request.Context(), id)
app, svcErr := h.svc.GetAppByID(c.Request.Context(), c.Param("id"))
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
response.OK(c, app)
c.JSON(http.StatusOK, h.serializeIntegrationApp(c, getAccountID(c), *app))
}
// --- Integration Hooks CRUD ---
@@ -86,12 +83,12 @@ func (h *IntegrationHookHandler) GetHook(c *gin.Context) {
return
}
hook, svcErr := h.svc.Get(c.Request.Context(), id)
hook, svcErr := h.svc.GetScoped(c.Request.Context(), getAccountID(c), id)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
response.OK(c, hook)
c.JSON(http.StatusOK, serializeIntegrationHook(*hook))
}
// CreateHook creates a new integration hook.
@@ -103,22 +100,18 @@ func (h *IntegrationHookHandler) CreateHook(c *gin.Context) {
return
}
// Chatwoot: params.require(:hook) → {"hook": {...}}
var wrapper struct {
Hook service.CreateHookRequest `json:"hook"`
}
if err := c.ShouldBindJSON(&wrapper); err != nil {
var req service.CreateHookRequest
if err := bindJSONWrappedOrRaw(c, "hook", &req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
return
}
req := wrapper.Hook
hook, svcErr := h.svc.Create(c.Request.Context(), accountID, req)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
response.OK(c, hook)
c.JSON(http.StatusOK, serializeIntegrationHook(*hook))
}
// UpdateHook updates an existing integration hook.
@@ -130,22 +123,19 @@ func (h *IntegrationHookHandler) UpdateHook(c *gin.Context) {
return
}
// Chatwoot: params.require(:hook) → {"hook": {...}}
var wrapper struct {
Hook service.UpdateHookRequest `json:"hook"`
}
if err := c.ShouldBindJSON(&wrapper); err != nil {
accountID := getAccountID(c)
var req service.UpdateHookRequest
if err := bindJSONWrappedOrRaw(c, "hook", &req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
return
}
req := wrapper.Hook
hook, svcErr := h.svc.Update(c.Request.Context(), id, req)
hook, svcErr := h.svc.UpdateScoped(c.Request.Context(), accountID, id, req)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
response.OK(c, hook)
c.JSON(http.StatusOK, serializeIntegrationHook(*hook))
}
// DeleteHook deletes an integration hook.
@@ -157,11 +147,11 @@ func (h *IntegrationHookHandler) DeleteHook(c *gin.Context) {
return
}
if svcErr := h.svc.Delete(c.Request.Context(), id); svcErr != nil {
if svcErr := h.svc.DeleteScoped(c.Request.Context(), getAccountID(c), id); svcErr != nil {
handleServiceError(c, svcErr)
return
}
response.OK(c, gin.H{"id": id})
c.Status(http.StatusOK)
}
// ProcessHookEvent processes an incoming event for a hook.
@@ -187,7 +177,143 @@ func (h *IntegrationHookHandler) ProcessHookEvent(c *gin.Context) {
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, svcErr.Error())
return
}
response.OK(c, gin.H{"message": "event processed"})
c.JSON(http.StatusOK, gin.H{"message": "event processed"})
}
func (h *IntegrationHookHandler) serializeIntegrationApps(c *gin.Context, accountID uint, apps []model.IntegrationApp) []gin.H {
payload := make([]gin.H, 0, len(apps))
for _, app := range apps {
payload = append(payload, h.serializeIntegrationApp(c, accountID, app))
}
return payload
}
func (h *IntegrationHookHandler) serializeIntegrationApp(c *gin.Context, accountID uint, app model.IntegrationApp) gin.H {
appID := integrationAppID(app)
hooks, _ := h.svc.ListHooksForApp(c.Request.Context(), accountID, appID)
serializedHooks := make([]gin.H, 0, len(hooks))
for _, hook := range hooks {
serializedHooks = append(serializedHooks, serializeIntegrationHook(hook))
}
return gin.H{
"id": appID,
"name": app.Name,
"description": app.Description,
"short_description": app.Description,
"enabled": len(serializedHooks) > 0,
"action": app.ActionURL,
"button": app.ActionURL,
"hook_type": integrationAppHookType(appID),
"allow_multiple_hooks": integrationAppAllowsMultipleHooks(appID),
"settings_form_schema": integrationAppSettingsFormSchema(appID),
"visible_properties": integrationAppVisibleProperties(appID),
"hooks": serializedHooks,
}
}
func serializeIntegrationHook(hook model.IntegrationHook) gin.H {
settings := gin.H{}
if len(hook.Settings) > 0 {
_ = json.Unmarshal(hook.Settings, &settings)
}
payload := gin.H{
"id": hook.ID,
"app_id": integrationHookAppID(hook),
"status": hook.Status != model.HookStatusInactive,
"account_id": hook.AccountID,
"hook_type": integrationHookKind(hook),
"settings": settings,
}
if hook.ReferenceID != "" {
payload["reference_id"] = hook.ReferenceID
}
if hook.InboxID != nil && *hook.InboxID != 0 {
inbox := gin.H{"id": *hook.InboxID}
if hook.Inbox != nil && hook.Inbox.ID != 0 {
inbox["name"] = hook.Inbox.Name
}
payload["inbox"] = inbox
}
return payload
}
func integrationAppID(app model.IntegrationApp) string {
if app.HookType != "" {
return string(app.HookType)
}
return strings.ToLower(strings.ReplaceAll(app.Name, " ", "_"))
}
func integrationHookAppID(hook model.IntegrationHook) string {
if hook.AppID != "" {
return hook.AppID
}
return string(hook.HookType)
}
func integrationAppHookType(appID string) string {
if appID == "dialogflow" {
return "inbox"
}
return "account"
}
func integrationAppAllowsMultipleHooks(appID string) bool {
return appID == "webhook" || appID == "dashboard_apps" || appID == "dialogflow"
}
func integrationAppSettingsFormSchema(appID string) []gin.H {
schemas := map[string][]gin.H{
"openai": {
{"label": "API Key", "type": "text", "name": "api_key", "validation": "required"},
{"label": "Show label suggestions", "type": "checkbox", "name": "label_suggestion", "validation": ""},
},
"dialogflow": {
{"label": "Dialogflow Project ID", "type": "text", "name": "project_id", "validation": "required", "validationName": "Project Id"},
{"label": "Dialogflow Project Key File", "type": "textarea", "name": "credentials", "validation": "required|JSON", "validationName": "Credentials", "validation-messages": gin.H{"JSON": "Invalid JSON", "required": "Credentials is required"}},
{"label": "Dialogflow Region", "type": "select", "name": "region", "default": "global", "options": []gin.H{{"label": "Global - Default", "value": "global"}, {"label": "AS-NE1 - Tokyo, Japan", "value": "asia-northeast1"}, {"label": "AU-SE1 - Sydney, Australia", "value": "australia-southeast1"}, {"label": "EU-W1 - St. Ghislain, Belgium", "value": "europe-west1"}, {"label": "EU-W2 - London, England", "value": "europe-west2"}}},
{"label": "Language Code", "type": "select", "name": "language_code", "default": "en-US", "help": "Language code for Dialogflow agent. Use \"auto\" to detect from contact language.", "options": []gin.H{{"label": "Auto-detect from contact", "value": "auto"}, {"label": "English (US)", "value": "en-US"}, {"label": "English (UK)", "value": "en-GB"}, {"label": "Spanish (Spain)", "value": "es-ES"}, {"label": "Spanish (Latin America)", "value": "es-419"}, {"label": "French", "value": "fr-FR"}, {"label": "German", "value": "de-DE"}, {"label": "Portuguese (Brazil)", "value": "pt-BR"}, {"label": "Portuguese (Portugal)", "value": "pt-PT"}, {"label": "Italian", "value": "it-IT"}, {"label": "Japanese", "value": "ja-JP"}, {"label": "Korean", "value": "ko-KR"}, {"label": "Chinese (Simplified)", "value": "zh-CN"}, {"label": "Chinese (Traditional)", "value": "zh-TW"}, {"label": "Hindi", "value": "hi-IN"}, {"label": "Arabic", "value": "ar"}, {"label": "Russian", "value": "ru-RU"}, {"label": "Dutch", "value": "nl-NL"}, {"label": "Polish", "value": "pl-PL"}, {"label": "Turkish", "value": "tr-TR"}, {"label": "Thai", "value": "th-TH"}, {"label": "Vietnamese", "value": "vi-VN"}, {"label": "Indonesian", "value": "id-ID"}}},
},
"google_translate": {
{"label": "Google Cloud Project ID", "type": "text", "name": "project_id", "validation": "required", "validationName": "Project Id"},
{"label": "Google Cloud Project Key File", "type": "textarea", "name": "credentials", "validation": "required|JSON", "validationName": "Credentials", "validation-messages": gin.H{"JSON": "Invalid JSON", "required": "Credentials is required"}},
},
"dyte": {
{"label": "Organization ID", "type": "text", "name": "organization_id", "validation": "required"},
{"label": "API Key", "type": "text", "name": "api_key", "validation": "required"},
},
"leadsquared": {
{"label": "Access Key", "type": "text", "name": "access_key", "validation": "required"},
{"label": "Secret Key", "type": "text", "name": "secret_key", "validation": "required"},
{"label": "Endpoint URL", "type": "text", "name": "endpoint_url", "validation": "required|url"},
{"label": "App URL", "type": "text", "name": "app_url", "validation": "required|url"},
},
}
if schema, ok := schemas[appID]; ok {
return schema
}
return []gin.H{}
}
func integrationAppVisibleProperties(appID string) []string {
properties := map[string][]string{
"openai": {"api_key", "label_suggestion"},
"dialogflow": {"project_id", "region", "language_code"},
"google_translate": {"project_id"},
"dyte": {"organization_id"},
"leadsquared": {"access_key", "endpoint_url", "app_url"},
}
if visibleProperties, ok := properties[appID]; ok {
return visibleProperties
}
return []string{}
}
func integrationHookKind(hook model.IntegrationHook) string {
if hook.InboxID != nil && *hook.InboxID != 0 {
return "inbox"
}
return "account"
}
// RegisterIntegrationHookRoutes registers integration hook routes on a router group.
@@ -195,6 +321,7 @@ func RegisterIntegrationHookRoutes(g *gin.RouterGroup, h *IntegrationHookHandler
// Integration apps catalog
apps := g.Group("/apps")
{
apps.GET("", h.ListApps)
apps.GET("/", h.ListApps)
apps.GET("/:id", h.GetApp)
}
@@ -202,10 +329,13 @@ func RegisterIntegrationHookRoutes(g *gin.RouterGroup, h *IntegrationHookHandler
// Integration hooks CRUD + process event
hooks := g.Group("/hooks")
{
hooks.GET("", h.ListHooks)
hooks.GET("/", h.ListHooks)
hooks.GET("/:id", h.GetHook)
hooks.POST("", h.CreateHook)
hooks.POST("/", h.CreateHook)
hooks.PUT("/:id", h.UpdateHook)
hooks.PATCH("/:id", h.UpdateHook)
hooks.DELETE("/:id", h.DeleteHook)
hooks.POST("/:id/process_event", h.ProcessHookEvent)
}
@@ -53,20 +53,7 @@ func (s *IntegrationHookHandlerSuite) SetupSuite() {
r := gin.New()
r.Use(gin.Recovery(), mockAuthMiddlewareForHook())
g := r.Group("/api/v1/accounts/:account_id/integrations")
apps := g.Group("/apps")
{
apps.GET("/", s.handler.ListApps)
apps.GET("/:id", s.handler.GetApp)
}
hooks := g.Group("/hooks")
{
hooks.GET("/", s.handler.ListHooks)
hooks.GET("/:id", s.handler.GetHook)
hooks.POST("/", s.handler.CreateHook)
hooks.PUT("/:id", s.handler.UpdateHook)
hooks.DELETE("/:id", s.handler.DeleteHook)
hooks.POST("/:id/process_event", s.handler.ProcessHookEvent)
}
RegisterIntegrationHookRoutes(g, s.handler)
s.router = r
}
@@ -105,6 +92,7 @@ func (s *IntegrationHookHandlerSuite) createHook(accountID uint, hookType model.
token := "test_token_" + strconv.Itoa(hookTokenCounter)
hook := &model.IntegrationHook{
AccountID: accountID,
AppID: string(hookType),
HookType: hookType,
Status: model.HookStatusActive,
URL: url,
@@ -129,10 +117,10 @@ func (s *IntegrationHookHandlerSuite) TestListApps_Success() {
var body map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &body))
s.Equal(true, body["success"])
data := body["data"].([]interface{})
data := body["payload"].([]interface{})
s.Len(data, 2)
app := data[0].(map[string]interface{})
s.Contains(app, "hooks")
}
func (s *IntegrationHookHandlerSuite) TestListApps_Empty() {
@@ -143,9 +131,7 @@ func (s *IntegrationHookHandlerSuite) TestListApps_Empty() {
var body map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &body))
s.Equal(true, body["success"])
data := body["data"].([]interface{})
data := body["payload"].([]interface{})
s.Len(data, 0)
}
@@ -154,26 +140,24 @@ func (s *IntegrationHookHandlerSuite) TestListApps_Empty() {
// =====================
func (s *IntegrationHookHandlerSuite) TestGetApp_Success() {
app := s.createApp("Slack App", model.HookTypeSlack)
s.createApp("Slack App", model.HookTypeSlack)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/1/integrations/apps/"+s.uid(app.ID), nil)
req, _ := http.NewRequest("GET", "/api/v1/accounts/1/integrations/apps/slack", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var body map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &body))
s.Equal(true, body["success"])
data := body["data"].(map[string]interface{})
s.Equal("Slack App", data["name"])
s.Equal("slack", body["id"])
s.Equal("Slack App", body["name"])
}
func (s *IntegrationHookHandlerSuite) TestGetApp_InvalidID() {
func (s *IntegrationHookHandlerSuite) TestGetApp_UnknownID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/1/integrations/apps/abc", nil)
req, _ := http.NewRequest("GET", "/api/v1/accounts/1/integrations/apps/unknown", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusBadRequest, w.Code)
s.Equal(http.StatusNotFound, w.Code)
}
func (s *IntegrationHookHandlerSuite) TestGetApp_NotFound() {
@@ -232,11 +216,9 @@ func (s *IntegrationHookHandlerSuite) TestGetHook_Success() {
var body map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &body))
s.Equal(true, body["success"])
data := body["data"].(map[string]interface{})
s.Equal("webhook", data["hook_type"])
s.Equal("https://example.com/hook", data["url"])
s.Equal("webhook", body["app_id"])
s.Equal("account", body["hook_type"])
s.Equal(true, body["status"])
}
func (s *IntegrationHookHandlerSuite) TestGetHook_NotFound() {
@@ -251,10 +233,12 @@ func (s *IntegrationHookHandlerSuite) TestGetHook_NotFound() {
// =====================
func (s *IntegrationHookHandlerSuite) TestCreateHook_Success() {
body, _ := json.Marshal(map[string]interface{}{"hook": map[string]string{
"hook_type": "webhook",
"url": "https://example.com/new_hook",
}})
body, _ := json.Marshal(map[string]interface{}{
"app_id": "webhook",
"settings": map[string]interface{}{
"project_id": "project-1",
},
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/integrations/hooks/", bytes.NewBuffer(body))
@@ -264,12 +248,12 @@ func (s *IntegrationHookHandlerSuite) TestCreateHook_Success() {
var resp map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.Equal(true, resp["success"])
data := resp["data"].(map[string]interface{})
s.Equal("webhook", data["hook_type"])
s.Equal("https://example.com/new_hook", data["url"])
s.NotNil(data["id"])
s.Equal("webhook", resp["app_id"])
s.Equal("account", resp["hook_type"])
s.Equal(true, resp["status"])
s.NotNil(resp["id"])
settings := resp["settings"].(map[string]interface{})
s.Equal("project-1", settings["project_id"])
}
func (s *IntegrationHookHandlerSuite) TestCreateHook_InvalidJSON() {
@@ -295,9 +279,12 @@ func (s *IntegrationHookHandlerSuite) TestCreateHook_InvalidAccountID() {
func (s *IntegrationHookHandlerSuite) TestUpdateHook_Success() {
hook := s.createHook(1, model.HookTypeWebhook, "https://example.com/hook")
body, _ := json.Marshal(map[string]interface{}{"hook": map[string]string{
"url": "https://example.com/updated_hook",
"status": "inactive",
body, _ := json.Marshal(map[string]interface{}{"hook": map[string]interface{}{
"status": "disabled",
"reference_id": "ref-123",
"settings": map[string]interface{}{
"channel": "support",
},
}})
w := httptest.NewRecorder()
@@ -308,11 +295,10 @@ func (s *IntegrationHookHandlerSuite) TestUpdateHook_Success() {
var resp map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.Equal(true, resp["success"])
data := resp["data"].(map[string]interface{})
s.Equal("https://example.com/updated_hook", data["url"])
s.Equal("inactive", data["status"])
s.Equal(false, resp["status"])
s.Equal("ref-123", resp["reference_id"])
settings := resp["settings"].(map[string]interface{})
s.Equal("support", settings["channel"])
}
func (s *IntegrationHookHandlerSuite) TestUpdateHook_NotFound() {
@@ -357,21 +343,14 @@ func (s *IntegrationHookHandlerSuite) TestDeleteHook_Success() {
req, _ := http.NewRequest("DELETE", "/api/v1/accounts/1/integrations/hooks/"+s.uid(hook.ID), nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.Equal(true, resp["success"])
data := resp["data"].(map[string]interface{})
s.Equal(float64(hook.ID), data["id"])
s.Empty(w.Body.String())
}
func (s *IntegrationHookHandlerSuite) TestDeleteHook_NotFound() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/v1/accounts/1/integrations/hooks/9999", nil)
s.router.ServeHTTP(w, req)
// GORM soft-delete on non-existent ID returns nil error → handler returns 200 with {"id": 9999}
s.Equal(http.StatusOK, w.Code)
s.Equal(http.StatusNotFound, w.Code)
}
func (s *IntegrationHookHandlerSuite) TestDeleteHook_InvalidID() {
@@ -401,10 +380,7 @@ func (s *IntegrationHookHandlerSuite) TestProcessHookEvent_Success() {
var resp map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.Equal(true, resp["success"])
data := resp["data"].(map[string]interface{})
s.Equal("event processed", data["message"])
s.Equal("event processed", resp["message"])
}
func (s *IntegrationHookHandlerSuite) TestProcessHookEvent_NotFound() {
@@ -442,6 +418,7 @@ func (s *IntegrationHookHandlerSuite) TestProcessHookEvent_InactiveHook() {
hookTokenCounter++
hook := &model.IntegrationHook{
AccountID: 1,
AppID: "webhook",
HookType: model.HookTypeWebhook,
Status: model.HookStatusInactive,
URL: "https://example.com/hook",
@@ -462,4 +439,4 @@ func (s *IntegrationHookHandlerSuite) TestProcessHookEvent_InactiveHook() {
// TestIntegrationHookHandlerSuite runs the suite.
func TestIntegrationHookHandlerSuite(t *testing.T) {
suite.Run(t, new(IntegrationHookHandlerSuite))
}
}
+18 -15
View File
@@ -24,12 +24,15 @@ import (
type IntegrationHook struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AccountID uint `gorm:"not null;index" json:"account_id"`
InboxID *uint `gorm:"index" json:"inbox_id,omitempty"` // nil = account-level hook
HookType HookType `gorm:"size:50;not null;index" json:"hook_type"` // webhook/slack/shopify/linear/notion
Status HookStatus `gorm:"size:20;default:'active'" json:"status"` // active/inactive
URL string `gorm:"size:1024" json:"url"` // webhook callback URL
AppID string `gorm:"size:100;index" json:"app_id,omitempty"`
InboxID *uint `gorm:"index" json:"inbox_id,omitempty"` // nil = account-level hook
Inbox *Inbox `gorm:"foreignKey:InboxID" json:"inbox,omitempty"`
HookType HookType `gorm:"size:50;not null;index" json:"hook_type"` // webhook/slack/shopify/linear/notion
Status HookStatus `gorm:"size:20;default:'active'" json:"status"` // active/inactive
URL string `gorm:"size:1024" json:"url"` // webhook callback URL
AccessToken string `gorm:"size:256;uniqueIndex" json:"access_token,omitempty"` // API token for the hook
Settings datatypes.JSON `gorm:"type:jsonb" json:"settings"` // provider-specific config (Slack channel_id, Shopify shop_domain, etc.)
ReferenceID string `gorm:"size:255" json:"reference_id,omitempty"`
Settings datatypes.JSON `gorm:"type:jsonb" json:"settings"` // provider-specific config (Slack channel_id, Shopify shop_domain, etc.)
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"`
@@ -38,15 +41,15 @@ type IntegrationHook struct {
// IntegrationApp represents an available integration app definition.
// Reference: Chatwoot Integrations::App — catalog of supported integrations
type IntegrationApp struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"size:255;not null" json:"name"`
Description string `gorm:"size:512" json:"description"`
HookType HookType `gorm:"size:50;not null;uniqueIndex" json:"hook_type"`
Icon string `gorm:"size:512" json:"icon"` // icon URL or CSS class
ActionURL string `gorm:"size:1024" json:"action_url"` // frontend action URL for the integration setup page
Enabled bool `gorm:"default:true" json:"enabled"` // whether the integration is available for installation
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"size:255;not null" json:"name"`
Description string `gorm:"size:512" json:"description"`
HookType HookType `gorm:"size:50;not null;uniqueIndex" json:"hook_type"`
Icon string `gorm:"size:512" json:"icon"` // icon URL or CSS class
ActionURL string `gorm:"size:1024" json:"action_url"` // frontend action URL for the integration setup page
Enabled bool `gorm:"default:true" json:"enabled"` // whether the integration is available for installation
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
// SlackSettings holds Slack integration-specific configuration.
@@ -60,7 +63,7 @@ type SlackSettings struct {
// ShopifySettings holds Shopify integration-specific configuration.
// Reference: Chatwoot Integrations::ShopifyController settings
type ShopifySettings struct {
ShopDomain string `json:"shop_domain"`
ShopDomain string `json:"shop_domain"`
AccessToken string `json:"access_token,omitempty"`
}
+31 -2
View File
@@ -22,7 +22,19 @@ func NewIntegrationHookRepo(db *gorm.DB) *IntegrationHookRepo {
// GetByID retrieves an integration hook by primary key.
func (r *IntegrationHookRepo) GetByID(ctx context.Context, id uint) (*model.IntegrationHook, error) {
var hook model.IntegrationHook
if err := r.db.WithContext(ctx).First(&hook, id).Error; err != nil {
if err := r.db.WithContext(ctx).Preload("Inbox").First(&hook, id).Error; err != nil {
return nil, err
}
return &hook, nil
}
// GetByAccountAndID retrieves an integration hook scoped to an account.
func (r *IntegrationHookRepo) GetByAccountAndID(ctx context.Context, accountID, id uint) (*model.IntegrationHook, error) {
var hook model.IntegrationHook
if err := r.db.WithContext(ctx).
Preload("Inbox").
Where("account_id = ?", accountID).
First(&hook, id).Error; err != nil {
return nil, err
}
return &hook, nil
@@ -38,10 +50,22 @@ func (r *IntegrationHookRepo) FindByAccount(ctx context.Context, accountID uint,
return nil, 0, err
}
err := q.Offset(offset).Limit(limit).Order("id ASC").Find(&hooks).Error
err := q.Preload("Inbox").Offset(offset).Limit(limit).Order("id ASC").Find(&hooks).Error
return hooks, total, err
}
// FindByAccountAndApp retrieves integration hooks for a Chatwoot app id.
func (r *IntegrationHookRepo) FindByAccountAndApp(ctx context.Context, accountID uint, appID string) ([]model.IntegrationHook, error) {
var hooks []model.IntegrationHook
err := r.db.WithContext(ctx).
Preload("Inbox").
Where("account_id = ?", accountID).
Where("app_id = ? OR (app_id = '' AND hook_type = ?)", appID, appID).
Order("id ASC").
Find(&hooks).Error
return hooks, err
}
// FindByAccountAndType retrieves integration hooks for an account filtered by hook type.
func (r *IntegrationHookRepo) FindByAccountAndType(ctx context.Context, accountID uint, hookType model.HookType) ([]model.IntegrationHook, error) {
var hooks []model.IntegrationHook
@@ -114,3 +138,8 @@ func (r *IntegrationAppRepo) GetByHookType(ctx context.Context, hookType model.H
}
return &app, nil
}
// GetByAppID retrieves an integration app by Chatwoot app id.
func (r *IntegrationAppRepo) GetByAppID(ctx context.Context, appID string) (*model.IntegrationApp, error) {
return r.GetByHookType(ctx, model.HookType(appID))
}
+111 -6
View File
@@ -39,9 +39,10 @@ func (s *IntegrationHookService) SetRegistry(registry *WebhookProcessorRegistry)
}
// CreateHookRequest is the DTO for creating an integration hook.
// Reference: Chatwoot HooksController#create — params: {hook_type, url, inbox_id, settings}
// Reference: Chatwoot HooksController#create — params: {app_id, inbox_id, settings}
type CreateHookRequest struct {
HookType string `json:"hook_type" validate:"required,oneof=webhook slack shopify linear notion"`
AppID string `json:"app_id,omitempty"`
HookType string `json:"hook_type,omitempty"`
URL string `json:"url,omitempty" validate:"omitempty,url"`
InboxID *uint `json:"inbox_id,omitempty"`
Settings map[string]interface{} `json:"settings,omitempty"`
@@ -49,9 +50,24 @@ type CreateHookRequest struct {
// UpdateHookRequest is the DTO for updating an integration hook.
type UpdateHookRequest struct {
URL string `json:"url,omitempty" validate:"omitempty,url"`
Status string `json:"status,omitempty" validate:"omitempty,oneof=active inactive"`
Settings map[string]interface{} `json:"settings,omitempty"`
URL string `json:"url,omitempty" validate:"omitempty,url"`
Status string `json:"status,omitempty" validate:"omitempty,oneof=active inactive enabled disabled"`
ReferenceID string `json:"reference_id,omitempty"`
Settings map[string]interface{} `json:"settings,omitempty"`
}
var supportedIntegrationAppIDs = map[string]struct{}{
"webhook": {},
"dashboard_apps": {},
"slack": {},
"shopify": {},
"linear": {},
"notion": {},
"dialogflow": {},
"openai": {},
"google_translate": {},
"dyte": {},
"leadsquared": {},
}
// List returns integration hooks for an account, paginated.
@@ -64,11 +80,20 @@ func (s *IntegrationHookService) Get(ctx context.Context, id uint) (*model.Integ
return s.hookRepo.GetByID(ctx, id)
}
// GetScoped returns a single integration hook scoped to an account.
func (s *IntegrationHookService) GetScoped(ctx context.Context, accountID, id uint) (*model.IntegrationHook, error) {
return s.hookRepo.GetByAccountAndID(ctx, accountID, id)
}
// Create creates a new integration hook for an account.
func (s *IntegrationHookService) Create(ctx context.Context, accountID uint, req CreateHookRequest) (*model.IntegrationHook, error) {
if err := pkgvalidator.ValidateStruct(req); err != nil {
return nil, fmt.Errorf("validation failed: %w", err)
}
appID := normalizeIntegrationAppID(req)
if err := validateIntegrationAppID(appID); err != nil {
return nil, err
}
// Generate a unique access token for the hook
token, err := generateHookAccessToken()
@@ -78,8 +103,9 @@ func (s *IntegrationHookService) Create(ctx context.Context, accountID uint, req
hook := &model.IntegrationHook{
AccountID: accountID,
AppID: appID,
InboxID: req.InboxID,
HookType: model.HookType(req.HookType),
HookType: model.HookType(appID),
Status: model.HookStatusActive,
URL: req.URL,
AccessToken: token,
@@ -131,6 +157,39 @@ func (s *IntegrationHookService) Update(ctx context.Context, id uint, req Update
return hook, nil
}
// UpdateScoped updates an existing integration hook scoped to an account.
func (s *IntegrationHookService) UpdateScoped(ctx context.Context, accountID, id uint, req UpdateHookRequest) (*model.IntegrationHook, error) {
if err := pkgvalidator.ValidateStruct(req); err != nil {
return nil, fmt.Errorf("validation failed: %w", err)
}
hook, err := s.hookRepo.GetByAccountAndID(ctx, accountID, id)
if err != nil {
return nil, fmt.Errorf("integration hook not found: %w", err)
}
if req.Status != "" {
hook.Status = normalizeIntegrationHookStatus(req.Status)
}
if req.ReferenceID != "" {
hook.ReferenceID = req.ReferenceID
}
if req.Settings != nil {
settingsJSON, err := json.Marshal(req.Settings)
if err != nil {
return nil, fmt.Errorf("failed to marshal settings: %w", err)
}
hook.Settings = settingsJSON
}
if err := s.hookRepo.Update(ctx, hook); err != nil {
return nil, fmt.Errorf("failed to update integration hook: %w", err)
}
applogger.L().Infof("Integration hook updated: id=%d", id)
return s.hookRepo.GetByAccountAndID(ctx, accountID, id)
}
// Delete deletes an integration hook by ID.
func (s *IntegrationHookService) Delete(ctx context.Context, id uint) error {
if err := s.hookRepo.Delete(ctx, id); err != nil {
@@ -140,6 +199,14 @@ func (s *IntegrationHookService) Delete(ctx context.Context, id uint) error {
return nil
}
// DeleteScoped deletes an integration hook scoped to an account.
func (s *IntegrationHookService) DeleteScoped(ctx context.Context, accountID, id uint) error {
if _, err := s.hookRepo.GetByAccountAndID(ctx, accountID, id); err != nil {
return fmt.Errorf("integration hook not found: %w", err)
}
return s.Delete(ctx, id)
}
// ProcessEvent processes an incoming event for a hook (e.g., Slack slash command callback, Shopify webhook).
// Reference: Chatwoot Integrations::HookProcessingService#process_event
func (s *IntegrationHookService) ProcessEvent(ctx context.Context, hookID uint, eventData map[string]interface{}) error {
@@ -164,11 +231,21 @@ func (s *IntegrationHookService) ListApps(ctx context.Context) ([]model.Integrat
return s.appRepo.List(ctx)
}
// ListHooksForApp returns account hooks for a Chatwoot integration app id.
func (s *IntegrationHookService) ListHooksForApp(ctx context.Context, accountID uint, appID string) ([]model.IntegrationHook, error) {
return s.hookRepo.FindByAccountAndApp(ctx, accountID, appID)
}
// GetApp returns a single integration app by ID.
func (s *IntegrationHookService) GetApp(ctx context.Context, id uint) (*model.IntegrationApp, error) {
return s.appRepo.GetByID(ctx, id)
}
// GetAppByID returns a single integration app by Chatwoot app id.
func (s *IntegrationHookService) GetAppByID(ctx context.Context, appID string) (*model.IntegrationApp, error) {
return s.appRepo.GetByAppID(ctx, appID)
}
// generateHookAccessToken creates a random 32-byte hex string for hook access tokens.
func generateHookAccessToken() (string, error) {
b := make([]byte, 32)
@@ -177,3 +254,31 @@ func generateHookAccessToken() (string, error) {
}
return hex.EncodeToString(b), nil
}
func normalizeIntegrationAppID(req CreateHookRequest) string {
if req.AppID != "" {
return req.AppID
}
return req.HookType
}
func validateIntegrationAppID(appID string) error {
if appID == "" {
return fmt.Errorf("app_id is required")
}
if _, ok := supportedIntegrationAppIDs[appID]; !ok {
return fmt.Errorf("unsupported integration app: %s", appID)
}
return nil
}
func normalizeIntegrationHookStatus(status string) model.HookStatus {
switch status {
case "enabled", "active":
return model.HookStatusActive
case "disabled", "inactive":
return model.HookStatusInactive
default:
return model.HookStatus(status)
}
}
@@ -42,6 +42,7 @@ func TestIntegrationHookService_Create(t *testing.T) {
}
hook, err := svc.Create(ctx, 1, req)
require.NoError(t, err)
assert.Equal(t, "webhook", hook.AppID)
assert.Equal(t, model.HookTypeWebhook, hook.HookType)
assert.Equal(t, "https://example.com/webhook", hook.URL)
assert.Equal(t, uint(1), hook.AccountID)
@@ -71,9 +72,27 @@ func TestIntegrationHookService_Create_Slack(t *testing.T) {
}
hook, err := svc.Create(ctx, 1, req)
require.NoError(t, err)
assert.Equal(t, "slack", hook.AppID)
assert.Equal(t, model.HookTypeSlack, hook.HookType)
}
func TestIntegrationHookService_Create_WithAppID(t *testing.T) {
hookRepo, appRepo := setupIntegrationHookTestDB(t)
svc := NewIntegrationHookService(hookRepo, appRepo, nil)
ctx := context.Background()
req := CreateHookRequest{
AppID: "openai",
Settings: map[string]interface{}{
"api_key": "sk-test",
},
}
hook, err := svc.Create(ctx, 1, req)
require.NoError(t, err)
assert.Equal(t, "openai", hook.AppID)
assert.Equal(t, model.HookType("openai"), hook.HookType)
}
func TestIntegrationHookService_Get(t *testing.T) {
hookRepo, appRepo := setupIntegrationHookTestDB(t)
svc := NewIntegrationHookService(hookRepo, appRepo, nil) // nil registry — webhook processing tests are separate
@@ -91,6 +110,22 @@ func TestIntegrationHookService_Get(t *testing.T) {
assert.Equal(t, hook.ID, found.ID)
}
func TestIntegrationHookService_GetScoped(t *testing.T) {
hookRepo, appRepo := setupIntegrationHookTestDB(t)
svc := NewIntegrationHookService(hookRepo, appRepo, nil)
ctx := context.Background()
hook, err := svc.Create(ctx, 1, CreateHookRequest{AppID: "webhook"})
require.NoError(t, err)
found, err := svc.GetScoped(ctx, 1, hook.ID)
require.NoError(t, err)
assert.Equal(t, hook.ID, found.ID)
_, err = svc.GetScoped(ctx, 2, hook.ID)
assert.Error(t, err)
}
func TestIntegrationHookService_List(t *testing.T) {
hookRepo, appRepo := setupIntegrationHookTestDB(t)
svc := NewIntegrationHookService(hookRepo, appRepo, nil) // nil registry — webhook processing tests are separate
@@ -133,6 +168,29 @@ func TestIntegrationHookService_Update(t *testing.T) {
assert.Equal(t, model.HookStatusInactive, updated.Status)
}
func TestIntegrationHookService_UpdateScoped(t *testing.T) {
hookRepo, appRepo := setupIntegrationHookTestDB(t)
svc := NewIntegrationHookService(hookRepo, appRepo, nil)
ctx := context.Background()
hook, err := svc.Create(ctx, 1, CreateHookRequest{AppID: "webhook"})
require.NoError(t, err)
updated, err := svc.UpdateScoped(ctx, 1, hook.ID, UpdateHookRequest{
Status: "disabled",
ReferenceID: "ref-1",
Settings: map[string]interface{}{
"channel": "support",
},
})
require.NoError(t, err)
assert.Equal(t, model.HookStatusInactive, updated.Status)
assert.Equal(t, "ref-1", updated.ReferenceID)
_, err = svc.UpdateScoped(ctx, 2, hook.ID, UpdateHookRequest{Status: "enabled"})
assert.Error(t, err)
}
func TestIntegrationHookService_Delete(t *testing.T) {
hookRepo, appRepo := setupIntegrationHookTestDB(t)
svc := NewIntegrationHookService(hookRepo, appRepo, nil) // nil registry — webhook processing tests are separate
@@ -151,6 +209,21 @@ func TestIntegrationHookService_Delete(t *testing.T) {
assert.Error(t, err, "should not find deleted hook")
}
func TestIntegrationHookService_DeleteScoped(t *testing.T) {
hookRepo, appRepo := setupIntegrationHookTestDB(t)
svc := NewIntegrationHookService(hookRepo, appRepo, nil)
ctx := context.Background()
hook, err := svc.Create(ctx, 1, CreateHookRequest{AppID: "webhook"})
require.NoError(t, err)
assert.Error(t, svc.DeleteScoped(ctx, 2, hook.ID))
require.NoError(t, svc.DeleteScoped(ctx, 1, hook.ID))
_, err = svc.Get(ctx, hook.ID)
assert.Error(t, err)
}
func TestIntegrationHookService_Create_WithInboxID(t *testing.T) {
hookRepo, appRepo := setupIntegrationHookTestDB(t)
svc := NewIntegrationHookService(hookRepo, appRepo, nil) // nil registry — webhook processing tests are separate
@@ -184,4 +257,4 @@ func TestIntegrationHookService_Create_WithSettings(t *testing.T) {
hook, err := svc.Create(ctx, 1, req)
require.NoError(t, err)
assert.NotNil(t, hook.Settings)
}
}
@@ -0,0 +1,7 @@
DROP INDEX IF EXISTS idx_integration_hooks_app_id;
DELETE FROM integration_apps WHERE hook_type IN ('dashboard_apps', 'openai', 'dialogflow', 'google_translate', 'dyte', 'leadsquared');
ALTER TABLE integration_hooks
DROP COLUMN IF EXISTS reference_id,
DROP COLUMN IF EXISTS app_id;
@@ -0,0 +1,24 @@
ALTER TABLE integration_hooks
ADD COLUMN IF NOT EXISTS app_id VARCHAR(100),
ADD COLUMN IF NOT EXISTS reference_id VARCHAR(255);
UPDATE integration_hooks
SET app_id = hook_type
WHERE app_id IS NULL OR app_id = '';
CREATE INDEX IF NOT EXISTS idx_integration_hooks_app_id ON integration_hooks(app_id) WHERE deleted_at IS NULL;
INSERT INTO integration_apps (name, description, hook_type, icon, action_url, enabled) VALUES
('Dashboard Apps', 'Embed custom dashboard apps in the conversation sidebar', 'dashboard_apps', '/assets/integrations/dashboard_apps.svg', '', TRUE),
('OpenAI', 'Connect OpenAI for label suggestions and AI-assisted workflows', 'openai', '/assets/integrations/openai.svg', '/openai', TRUE),
('Dialogflow', 'Connect Dialogflow agents to inboxes', 'dialogflow', '/assets/integrations/dialogflow.svg', '/dialogflow', TRUE),
('Google Translate', 'Connect Google Translate for message translation', 'google_translate', '/assets/integrations/google-translate.svg', '/google_translate', TRUE),
('Dyte', 'Connect Dyte for video meetings', 'dyte', '/assets/integrations/dyte.svg', '/dyte', TRUE),
('LeadSquared', 'Connect LeadSquared CRM activity sync', 'leadsquared', '/assets/integrations/leadsquared.svg', '/leadsquared', TRUE)
ON CONFLICT (hook_type) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
icon = EXCLUDED.icon,
action_url = EXCLUDED.action_url,
enabled = EXCLUDED.enabled,
updated_at = NOW();