From d241925b6a8cccc6fee958885bc4074b5f38afb9 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 6 Jun 2026 06:46:36 +0800 Subject: [PATCH] feat(integrations): align app hook payloads --- cmd/route_parity/main.go | 8 + docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 26 ++- docs/parity/gochat_routes.txt | 6 +- docs/parity/route_parity.md | 10 +- .../api/v1/integration_hook_handler.go | 188 +++++++++++++++--- .../v1/integration_hook_handler_suite_test.go | 109 ++++------ internal/model/integration_hook.go | 33 +-- internal/repository/integration_hook_repo.go | 33 ++- internal/service/integration_hook_service.go | 117 ++++++++++- .../service/integration_hook_service_test.go | 75 ++++++- ...egration_hooks_with_chatwoot_apps.down.sql | 7 + ...ntegration_hooks_with_chatwoot_apps.up.sql | 24 +++ 12 files changed, 504 insertions(+), 132 deletions(-) create mode 100644 migrations/000030_align_integration_hooks_with_chatwoot_apps.down.sql create mode 100644 migrations/000030_align_integration_hooks_with_chatwoot_apps.up.sql diff --git a/cmd/route_parity/main.go b/cmd/route_parity/main.go index 3583409b..3df5b0d2 100644 --- a/cmd/route_parity/main.go +++ b/cmd/route_parity/main.go @@ -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"}, diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 6d9e530f..475d126f 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -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`. diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index c2c9630b..bf28f000 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -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 diff --git a/docs/parity/route_parity.md b/docs/parity/route_parity.md index 3f81189a..1ef1e471 100644 --- a/docs/parity/route_parity.md +++ b/docs/parity/route_parity.md @@ -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 | diff --git a/internal/handler/api/v1/integration_hook_handler.go b/internal/handler/api/v1/integration_hook_handler.go index c3bf0b2a..dfca3b5c 100644 --- a/internal/handler/api/v1/integration_hook_handler.go +++ b/internal/handler/api/v1/integration_hook_handler.go @@ -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) } diff --git a/internal/handler/api/v1/integration_hook_handler_suite_test.go b/internal/handler/api/v1/integration_hook_handler_suite_test.go index 020801b9..40cce23b 100644 --- a/internal/handler/api/v1/integration_hook_handler_suite_test.go +++ b/internal/handler/api/v1/integration_hook_handler_suite_test.go @@ -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)) -} \ No newline at end of file +} diff --git a/internal/model/integration_hook.go b/internal/model/integration_hook.go index 84136aa7..1f90727f 100644 --- a/internal/model/integration_hook.go +++ b/internal/model/integration_hook.go @@ -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"` } diff --git a/internal/repository/integration_hook_repo.go b/internal/repository/integration_hook_repo.go index a497f05d..eeff3b31 100644 --- a/internal/repository/integration_hook_repo.go +++ b/internal/repository/integration_hook_repo.go @@ -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)) +} diff --git a/internal/service/integration_hook_service.go b/internal/service/integration_hook_service.go index 247ee786..0a8c3e10 100644 --- a/internal/service/integration_hook_service.go +++ b/internal/service/integration_hook_service.go @@ -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) + } +} diff --git a/internal/service/integration_hook_service_test.go b/internal/service/integration_hook_service_test.go index 13439c3e..f87787a5 100644 --- a/internal/service/integration_hook_service_test.go +++ b/internal/service/integration_hook_service_test.go @@ -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) -} \ No newline at end of file +} diff --git a/migrations/000030_align_integration_hooks_with_chatwoot_apps.down.sql b/migrations/000030_align_integration_hooks_with_chatwoot_apps.down.sql new file mode 100644 index 00000000..c555688d --- /dev/null +++ b/migrations/000030_align_integration_hooks_with_chatwoot_apps.down.sql @@ -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; diff --git a/migrations/000030_align_integration_hooks_with_chatwoot_apps.up.sql b/migrations/000030_align_integration_hooks_with_chatwoot_apps.up.sql new file mode 100644 index 00000000..22f6282a --- /dev/null +++ b/migrations/000030_align_integration_hooks_with_chatwoot_apps.up.sql @@ -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();