feat(canned-responses): align chatwoot payloads

This commit is contained in:
2026-06-06 13:36:44 +08:00
parent e8d08bb38e
commit 4e0113a394
9 changed files with 422 additions and 72 deletions
+1
View File
@@ -70,6 +70,7 @@ var criticalRoutes = []route{
{Method: "GET", Path: "/api/v1/accounts/:account_id/canned_responses/", Controller: "api/v1/accounts/canned_responses#index", Source: "routes.rb:114"},
{Method: "POST", Path: "/api/v1/accounts/:account_id/canned_responses/", Controller: "api/v1/accounts/canned_responses#create", Source: "routes.rb:114"},
{Method: "PATCH", Path: "/api/v1/accounts/:account_id/canned_responses/:id", Controller: "api/v1/accounts/canned_responses#update", Source: "routes.rb:114"},
{Method: "PUT", Path: "/api/v1/accounts/:account_id/canned_responses/:id", Controller: "api/v1/accounts/canned_responses#update", Source: "routes.rb:114"},
{Method: "DELETE", Path: "/api/v1/accounts/:account_id/canned_responses/:id", Controller: "api/v1/accounts/canned_responses#destroy", Source: "routes.rb:114"},
+17 -17
View File
@@ -49,14 +49,14 @@ Hermes task landing checklist:
## Current Baseline
- Current tracking checkpoint: 2026-06-06 canned response tracker checkpoint, prepared as `docs: track canned response parity`.
- Latest implementation checkpoint: `75f0e80 feat(dashboard-apps): align chatwoot payloads`.
- Latest documentation/tooling checkpoint: this checkpoint, prepared as `docs: track canned response parity`; this document is the active follow-up plan and supersedes `.hermes/plans/*`.
- Worktree status at this documentation checkpoint: dashboard apps from `reference/chatwoot/config/routes.rb:130` are implemented for the reused dashboard integrations settings flow. `GET/POST/PATCH/PUT/DELETE /api/v1/accounts/:account_id/dashboard_apps` now accept the raw payload sent by the frontend plus Rails-style wrapped bodies, return the Chatwoot dashboard-app partial shape, scope show/update/delete by current account, and preserve Chatwoot `204` delete behavior. Fresh reference/frontend inspection found the next owned drift in canned responses from `reference/chatwoot/config/routes.rb:114`: the reused frontend uses raw request bodies, `PATCH`, `search=`, raw array/object responses, account-scoped mutation lookup, and empty `200 OK` delete. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- Current tracking checkpoint: 2026-06-06 canned responses implementation checkpoint, prepared as `feat(canned-responses): align chatwoot payloads`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(canned-responses): align chatwoot payloads`.
- Latest documentation/tooling checkpoint: `e8d08bb docs: track canned response parity`; this document is the active follow-up plan and supersedes `.hermes/plans/*`.
- Worktree status at this implementation checkpoint: canned responses from `reference/chatwoot/config/routes.rb:114` are implemented for the reused dashboard canned-response settings flow. `GET/POST/PATCH/PUT/DELETE /api/v1/accounts/:account_id/canned_responses` now accept raw frontend bodies plus Rails-style wrappers, return raw arrays/objects, support `search=` ranking by short-code prefix/contains/content, scope show/update/delete by account, and return empty `200 OK` on delete. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. The next active slice returns to Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or a fresh reference/smoke finding.
- `go test ./...` passes.
- Route dump succeeds with `TOTAL: 907` after adding the two account Dyte integration routes.
- Route dump succeeds with `TOTAL: 910` after adding canned response no-trailing-slash frontend aliases and `PATCH` update.
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
- Tracked frontend-critical route audit covers 370 Chatwoot routes: 357 exact, 0 method-compatible, 13 parameter-compatible, 0 missing. The 13 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits, dashboard app member `:id` names, plus the public article `.md`/`.png` suffixes served through the same external article route dispatcher.
- Tracked frontend-critical route audit covers 371 Chatwoot routes: 358 exact, 0 method-compatible, 13 parameter-compatible, 0 missing. The 13 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits, dashboard app member `:id` names, plus the public article `.md`/`.png` suffixes served through the same external article route dispatcher.
- `/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.
@@ -78,19 +78,17 @@ Next ordered checkpoints:
| Order | Slice | Required outcome | Primary verification |
| --- | --- | --- | --- |
| 1 | P3.14 canned responses | Align the reused dashboard canned response API with Chatwoot raw payloads, `PATCH`, `search=`, raw serializers, account scope, and delete status. | Focused canned handler/service tests, regenerated route artifacts if `PATCH` is added, full `go test ./...`, and `git diff --check`. |
| 2 | Phase 2/3 audit pass | Route/controller/serializer drift found by B12 or new reference inspection is captured as named slices, not free-form TODOs. | Regenerated route parity artifacts and fixture-backed serializer tests. |
| 3 | Phase 6 placeholder burn-down | Remaining account/contact/conversation/message/inbox placeholder handlers are either real Chatwoot-compatible flows or explicitly tracked as unsupported reference gaps. | `rg` placeholder audit, route smoke, and endpoint-family tests. |
| 4 | B12 optional live smoke | Run the checked smoke harness in a full PostgreSQL/Redis/Meilisearch/Vite/Chrome environment and turn failures into named slices. | `docs/parity/frontend_smoke_report.md` pass/fail entries linked to owners. |
| 1 | Phase 2/3 audit pass | Route/controller/serializer drift found by B12 or new reference inspection is captured as named slices, not free-form TODOs. | Regenerated route parity artifacts and fixture-backed serializer tests. |
| 2 | Phase 6 placeholder burn-down | Remaining account/contact/conversation/message/inbox placeholder handlers are either real Chatwoot-compatible flows or explicitly tracked as unsupported reference gaps. | `rg` placeholder audit, route smoke, and endpoint-family tests. |
| 3 | B12 optional live smoke | Run the checked smoke harness in a full PostgreSQL/Redis/Meilisearch/Vite/Chrome environment and turn failures into named slices. | `docs/parity/frontend_smoke_report.md` pass/fail entries linked to owners. |
Execution queue for the next agent turn:
| Order | Slice ID | Why now | Required commit contents |
| --- | --- | --- | --- |
| 1 | P3.14 canned responses | Fresh reference/frontend inspection found concrete drift in an existing frontend-used route family. | Raw serializer/binder changes, `PATCH` route if missing, account-scoped service methods, search-param behavior, focused tests, route artifacts if route set changes, and tracker update. |
| 2 | Phase 2/3 drift audit | Route parity is currently green for the tracked set, but only fresh reference/frontend inspection proves the next missing reused-frontend path. | Named drift rows with inspected reference files, fixture tests where behavior is known, regenerated route artifacts if the route set changes. |
| 3 | Phase 6 placeholder audit | `chatwootParityStub` remains only as webhook nil-handler fallback, but every audit result must stay recorded so no frontend-critical stub becomes ownerless. | `rg` audit result, either a burn-down implementation or explicit non-frontend fallback classification, focused tests if code changes. |
| 4 | B12 live smoke | The harness is checked in; live pass/fail still needs the full PostgreSQL/Redis/Meilisearch/Vite/Chrome stack. | Updated smoke report with command, environment, failures, and linked owner rows. |
| 1 | Phase 2/3 drift audit | Route parity is currently green for the tracked set, but only fresh reference/frontend inspection proves the next missing reused-frontend path. | Named drift rows with inspected reference files, fixture tests where behavior is known, regenerated route artifacts if the route set changes. |
| 2 | Phase 6 placeholder audit | `chatwootParityStub` remains only as webhook nil-handler fallback, but every audit result must stay recorded so no frontend-critical stub becomes ownerless. | `rg` audit result, either a burn-down implementation or explicit non-frontend fallback classification, focused tests if code changes. |
| 3 | B12 live smoke | The harness is checked in; live pass/fail still needs the full PostgreSQL/Redis/Meilisearch/Vite/Chrome stack. | Updated smoke report with command, environment, failures, and linked owner rows. |
Slice lifecycle:
@@ -141,14 +139,14 @@ This table is the shortest authoritative handoff view. If an older lower section
| Priority | Workstream | Current state | Next checkpoint | Commit close rule |
| --- | --- | --- | --- | --- |
| 1 | P3.2a invitation/confirmation mail parity | Implemented for current non-SSO reference behavior: profile resend is no longer a TODO-only log, new invited agents and unconfirmed invited profile resends generate reset-password invitation links, normal unconfirmed profile resends generate confirmation links, `users.unconfirmed_email` is modeled for email-update routing, and fakeable/environment SMTP mailers keep default tests offline. | Keep in Review; reopen only if reused frontend smoke or fresh reference evidence exposes additional Devise confirmation states outside excluded SSO/SAML/LDAP/OIDC variants. | Focused profile and agent invitation tests, combined handler/service/repository/router/migrate/app tests, full `go test ./...`, and `git diff --check` passed. |
| 2 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 370-route critical set; dashboard `/app` shell routes from `routes.rb:19-20`, `.well-known` app association and custom-domain challenge routes from `routes.rb:657-660`, Twilio callback routes from `routes.rb:639-640`, enterprise Twilio voice routes from `routes.rb:643-646`, root Linear/Shopify/Notion OAuth callback routes from `routes.rb:630/634/654`, root Twitter/Google/Microsoft/Instagram/TikTok callback routes from `routes.rb:626/649-652`, assignment policy routes from `routes.rb:306-313`, help-center portal/category/article routes from `routes.rb:385-404`, public help-center portal/sitemap/article/category/search/article-detail routes from `routes.rb:590-601`, 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`, account integration app/hook routes from `routes.rb:345-348`, account Dyte routes from `routes.rb:357-358`, and dashboard app routes from `routes.rb:130` 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, account Dyte create/join payload behavior, dashboard app raw payload/serializer behavior, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, assignment policy CRUD and inbox binding payloads, Twilio inbound/status callbacks, enterprise Twilio voice callbacks, Linear/Shopify/Notion root integration callbacks, Shopify OAuth auth redirects, root channel OAuth callbacks, help-center portal/category/article payloads, dashboard app shell route behavior, app association JSON payloads, Cloudflare custom hostname verification, public widget popular-article lists, public help-center category list/show payloads, public portal show/default-locale payloads, public portal search payloads, public article show/markdown/tracking routes, and public sitemap XML now match the inspected Chatwoot contract. | Continue the next evidence-backed route/controller/serializer drift after dashboard app parity or from B12 findings. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. |
| 2 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 371-route critical set; dashboard `/app` shell routes from `routes.rb:19-20`, `.well-known` app association and custom-domain challenge routes from `routes.rb:657-660`, Twilio callback routes from `routes.rb:639-640`, enterprise Twilio voice routes from `routes.rb:643-646`, root Linear/Shopify/Notion OAuth callback routes from `routes.rb:630/634/654`, root Twitter/Google/Microsoft/Instagram/TikTok callback routes from `routes.rb:626/649-652`, assignment policy routes from `routes.rb:306-313`, help-center portal/category/article routes from `routes.rb:385-404`, public help-center portal/sitemap/article/category/search/article-detail routes from `routes.rb:590-601`, 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`, account integration app/hook routes from `routes.rb:345-348`, account Dyte routes from `routes.rb:357-358`, dashboard app routes from `routes.rb:130`, and canned response routes from `routes.rb:114` 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, account Dyte create/join payload behavior, dashboard app raw payload/serializer behavior, canned response raw payload/search/delete behavior, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, assignment policy CRUD and inbox binding payloads, Twilio inbound/status callbacks, enterprise Twilio voice callbacks, Linear/Shopify/Notion root integration callbacks, Shopify OAuth auth redirects, root channel OAuth callbacks, help-center portal/category/article payloads, dashboard app shell route behavior, app association JSON payloads, Cloudflare custom hostname verification, public widget popular-article lists, public help-center category list/show payloads, public portal show/default-locale payloads, public portal search payloads, public article show/markdown/tracking routes, and public sitemap XML now match the inspected Chatwoot contract. | Continue the next evidence-backed route/controller/serializer drift after canned response parity or from B12 findings. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. |
| 3 | Phase 6 placeholder audit | Widget/public/webhook critical placeholders are burned down; inbox WhatsApp health/register-webhook and sync-template drift are closed; refreshed `docs/parity/placeholder_audit.md` shows only webhook nil-handler fallbacks still call `chatwootParityStub`; dashboard conversation transcript/custom-attribute response drift and message retry status drift are closed. | Keep in Review; reopen only if fresh `rg`, route smoke, or B12 finds a frontend-reachable placeholder/stub in account/contact/conversation/message/inbox/widget/public paths. | `rg` placeholder audit and `scripts/parity_frontend_smoke.sh --check` are recorded; no reused-frontend blocker is ownerless. |
| 4 | 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. |
| 5 | 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. |
| 6 | 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. |
| 7 | P3.12 account Dyte integration API | Implemented for the reused dashboard VideoCallButton/Dyte flow: account-scoped create meeting and add-participant routes are registered, route parity tracks `routes.rb:357-358`, Dyte credentials are loaded from account integration hooks, `create_a_meeting` creates a Chatwoot `integrations` outgoing message with meeting content attributes, and add-participant returns Dyte token payloads while rejecting non-integration messages with the reference error. | Keep in Review; reopen if live smoke exposes provider payload/status-code drift or frontend message serializer gaps beyond the fakeable Dyte boundary. | Focused Dyte service and handler tests, combined handler/service/router/app/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
| 8 | P3.13 dashboard apps API | Implemented for the reused dashboard integration settings route: dashboard app CRUD routes are tracked, raw frontend bodies and Rails-style wrappers are accepted, list/show/create/update return the Chatwoot dashboard-app partial fields only, show/update/delete are account-scoped, content validation requires at least one `{ type: "frame", url: "http(s)://..." }`, and delete returns `204`. | Keep in Review; reopen if live settings smoke exposes error-body wording or iframe content validation drift beyond the current reference schema. | Focused dashboard app handler/service tests, combined handler/service/repository/router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
| 9 | P3.14 canned responses API | Todo. Fresh reference/frontend inspection shows current Go still uses local `{ success, data }` envelopes, wrapped-only bodies, `q`/`/search`, ID-only mutations, `204` delete, and no frontend `PATCH`; Chatwoot uses raw arrays/objects, raw frontend bodies via Rails parameter wrapping, `search=`, account-scoped update/delete, and empty `200 OK` delete. | Implement next before more broad drift work unless B12 live smoke produces a higher-severity blocker. | Focused canned handler/service tests must cover raw list/search/create/update/delete contracts, cross-account mutation `404`, search ranking, `PATCH` route parity, full `go test ./...`, route artifact regeneration if routes change, and `git diff --check`. |
| 9 | P3.14 canned responses API | Implemented for the reused dashboard canned-response settings flow: raw frontend bodies and Rails-style wrappers are accepted, no-trailing-slash frontend aliases and `PATCH` update are registered, list/search return raw arrays, create/update/show return raw Chatwoot fields, search uses `search=` and Chatwoot ranking, update/delete are account-scoped, and delete returns empty `200 OK`. | Keep in Review; reopen only if live settings smoke exposes validation/error wording or non-frontend compatibility alias drift beyond the current reference contract. | Focused canned handler/service tests, combined handler/canned/router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
| 10 | 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. |
| 11 | 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. |
| 12 | 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. |
@@ -177,7 +175,7 @@ These rows are the executable development plan from this point forward. A checkp
| 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. |
| P3.12 account Dyte integration API parity | `internal/router/router.go`, `internal/handler/api/v1/dyte_integration_handler.go`, `internal/service/dyte_integration_service.go`, `internal/service/message_service.go`, Dyte handler/service tests, `cmd/route_parity` | `reference/chatwoot/config/routes.rb:357-358`, `reference/chatwoot/app/controllers/api/v1/accounts/integrations/dyte_controller.rb`, `reference/chatwoot/lib/integrations/dyte/processor_service.rb`, `reference/chatwoot/lib/dyte.rb`, `reference/chatwoot/app/javascript/dashboard/api/integrations/dyte.js`, `reference/chatwoot/app/javascript/dashboard/components/widgets/VideoCallButton.vue`, `reference/chatwoot/app/javascript/dashboard/components-next/message/bubbles/Dyte.vue` | Account Dyte APIs now match the reused dashboard video-call boundary: `POST /integrations/dyte/create_a_meeting` and `POST /integrations/dyte/add_participant_to_meeting` are registered under account integrations and tracked in route parity; create resolves conversations by account display ID, enforces inbox access, loads account Dyte hook credentials, calls a fakeable Dyte backend, persists an outgoing `integrations` message with `content_attributes.type=dyte` and `data.meeting_id`, and returns the normal Chatwoot message serializer; add-participant resolves account-scoped messages, rejects non-`integrations` messages with `422 { error: { error: "Invalid message type. Action not permitted" } }`, and returns the Dyte participant token payload. | Review by `feat(integrations): align dyte meeting routes`; focused Dyte service tests, focused Dyte handler tests, combined handler/service/router/app/route-parity tests, route dump/parity regenerated to `TOTAL: 907` and `355 exact, 9 parameter-compatible, 0 missing out of 364`, full `go test ./...`, and `git diff --check` passed. |
| P3.13 dashboard apps API parity | `internal/router/router.go`, `internal/handler/api/v1/dashboard_app_handler.go`, `internal/service/dashboard_app_service.go`, `internal/model/dashboard_app.go`, `internal/repository/dashboard_app_repo.go`, dashboard app handler/service tests, `cmd/route_parity` | `reference/chatwoot/config/routes.rb:130`, `reference/chatwoot/app/controllers/api/v1/accounts/dashboard_apps_controller.rb`, `reference/chatwoot/app/views/api/v1/accounts/dashboard_apps/*.json.jbuilder`, `reference/chatwoot/app/views/api/v1/models/_dashboard_app.json.jbuilder`, `reference/chatwoot/app/models/dashboard_app.rb`, `reference/chatwoot/app/javascript/dashboard/api/dashboardApps.js`, `reference/chatwoot/app/javascript/dashboard/store/modules/dashboardApps.js`, dashboard integrations DashboardApps modal/index components | Dashboard app APIs now match the reused settings flow: the full Chatwoot resource route set is tracked; create/update accept raw frontend bodies and Rails-style `{ dashboard_app: ... }` wrappers; create returns `200 OK`; list returns a raw array; show/create/update return raw dashboard app objects containing only `id`, `title`, `content`, and `created_at`; show/update/delete are scoped by current account; delete returns `204`; and content validation requires at least one frame widget with an HTTP(S) URL. | Review by `feat(dashboard-apps): align chatwoot payloads`; focused dashboard app handler/service tests, combined handler/service/repository/router/route-parity tests, route dump/parity regenerated to `TOTAL: 907` and `357 exact, 13 parameter-compatible, 0 missing out of 370`, full `go test ./...`, and `git diff --check` passed. |
| P3.14 canned responses API parity | `internal/router/router.go`, `internal/handler/api/v1/canned_response_handler.go`, `internal/canned/service.go`, `internal/canned/model.go`, canned handler/service tests, `cmd/route_parity` | `reference/chatwoot/config/routes.rb:114`, `reference/chatwoot/app/controllers/api/v1/accounts/canned_responses_controller.rb`, `reference/chatwoot/app/models/canned_response.rb`, `reference/chatwoot/db/schema.rb`, `reference/chatwoot/app/javascript/dashboard/api/cannedResponse.js`, `reference/chatwoot/app/javascript/dashboard/store/modules/cannedResponse.js` | Align canned responses with the reused dashboard API: register frontend `PATCH /api/v1/accounts/:account_id/canned_responses/:id` if missing; keep only Chatwoot resource routes in the tracked critical set; accept raw frontend create/update bodies plus Rails-style `{ canned_response: ... }`; list returns a raw array; `search=` on index filters/ranks by short-code prefix, short-code contains, then content contains; create/update return raw objects with `id`, `account_id`, `short_code`, `content`, `created_at`, and `updated_at`; update/delete are account-scoped; delete returns empty `200 OK`; legacy `q` or `/search` may remain only as compatibility aliases and must not be the frontend contract. | Todo. Exit with focused canned handler tests for list/search/raw create/PATCH update/delete/cross-account `404`, service tests for account-scoped get/update/delete and ranking, route dump/parity regeneration if `PATCH` is added to routes or tracked set, full `go test ./...`, and `git diff --check`. |
| P3.14 canned responses API parity | `internal/router/router.go`, `internal/handler/api/v1/canned_response_handler.go`, `internal/canned/service.go`, `internal/canned/model.go`, canned handler/service tests, `cmd/route_parity` | `reference/chatwoot/config/routes.rb:114`, `reference/chatwoot/app/controllers/api/v1/accounts/canned_responses_controller.rb`, `reference/chatwoot/app/models/canned_response.rb`, `reference/chatwoot/db/schema.rb`, `reference/chatwoot/app/javascript/dashboard/api/cannedResponse.js`, `reference/chatwoot/app/javascript/dashboard/store/modules/cannedResponse.js` | Canned responses now match the reused dashboard API: frontend `PATCH /api/v1/accounts/:account_id/canned_responses/:id` and no-trailing-slash index/create aliases are registered; the tracked critical route set includes Rails `PATCH`; create/update accept raw frontend bodies plus Rails-style `{ canned_response: ... }`; list/search return raw arrays; `search=` filters and ranks by short-code prefix, short-code contains, then content contains; create/update/show return raw objects with `id`, `account_id`, `short_code`, `content`, `created_at`, and `updated_at`; update/delete are account-scoped; delete returns empty `200 OK`; legacy `q` and `/search` remain only compatibility aliases. | Review by `feat(canned-responses): align chatwoot payloads`; focused canned handler tests cover list/search/raw create/PATCH update/delete/cross-account `404`, service tests cover account-scoped get/update/delete and ranking, route dump/parity regenerated to `TOTAL: 910` and `358 exact, 13 parameter-compatible, 0 missing out of 371`, 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. |
@@ -219,6 +217,7 @@ This ledger records the committed parity checkpoints that future slices should b
| Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- |
| `feat(canned-responses): align chatwoot payloads` | Advances P3.14 canned response parity by matching Chatwoot `CannedResponsesController`, `CannedResponse#order_by_search`, schema fields, and reused dashboard `cannedResponse` API/store. GoChat now registers frontend no-trailing-slash index/create aliases plus `PATCH` update, accepts raw frontend bodies and Rails-style wrappers, returns raw arrays/objects with Chatwoot fields instead of local `{ success, data }` envelopes, supports `search=` ranking by short-code prefix/contains/content, scopes show/update/delete by account, and returns empty `200 OK` deletes while keeping legacy `q` and `/search` as compatibility aliases only. | `go test ./internal/handler/api/v1 -run CannedResponse -count=1`; `go test ./internal/canned -count=1`; `go test ./internal/handler/api/v1 ./internal/canned ./internal/router ./cmd/route_parity -run 'CannedResponse\|Router\|RouteParity' -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; full `go test ./...`; `git diff --check`. Route dump is `TOTAL: 910`; tracked route parity is `358 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 371`. | P3.14 moves to Review for current canned response settings evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
| `docs: track canned response parity` | Documentation-only checkpoint requested before continuing implementation. It records the already-clean `75f0e80 feat(dashboard-apps): align chatwoot payloads` baseline and lands fresh canned-response reference/frontend drift as P3.14, with owner files, reference files, required request/response/status contracts, route artifact rules, and test gates. | `git diff --check`. No Go code changed and no route artifacts changed. | Start `feat(canned-responses): align chatwoot payloads`, then continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke from concrete reference/smoke evidence. |
| `feat(dashboard-apps): align chatwoot payloads` | Advances P3.13 dashboard app parity by matching Chatwoot `DashboardAppsController`, dashboard app Jbuilder partials, model content schema, and reused dashboard integrations settings store/API. GoChat now tracks the dashboard app resource routes, accepts the raw frontend create/update bodies plus Rails-style wrappers, scopes show/update/delete by account, returns raw dashboard app partial payloads instead of local success/data envelopes or extended model fields, returns `200 OK` for create and `204` for delete, and validates content as at least one frame widget with an HTTP(S) URL. | `go test ./internal/handler/api/v1 -run DashboardApp -count=1`; `go test ./internal/service -run DashboardApp -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/repository ./internal/router ./cmd/route_parity -run 'DashboardApp\|Router\|Route' -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; full `go test ./...`; `git diff --check`. Route dump is `TOTAL: 907`; tracked route parity is `357 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 370`. | P3.13 moves to Review for current dashboard integrations settings evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
| `feat(integrations): align dyte meeting routes` | Advances P3.12 account Dyte integration parity by matching Chatwoot `Api::V1::Accounts::Integrations::DyteController`, `Integrations::Dyte::ProcessorService`, Dyte API wrapper behavior, and reused dashboard `api/integrations/dyte.js`/video-call components. GoChat now registers the account-scoped create-meeting and add-participant routes, tracks them in route parity, loads Dyte credentials from account integration hooks, creates outgoing `integrations` messages with Dyte meeting attributes, accepts `integrations` as a valid message content type, and returns participant token payloads through a fakeable Dyte backend. | `go test ./internal/service -run Dyte -count=1`; `go test ./internal/handler/api/v1 -run Dyte -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/router ./cmd/route_parity ./internal/app -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; full `go test ./...`; `git diff --check`. Route dump is `TOTAL: 907`; tracked route parity is `355 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 364`. | P3.12 moves to Review for current dashboard Dyte evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
@@ -2395,3 +2394,4 @@ Verification milestone gates:
- 2026-06-06: P3.2a invitation/confirmation mail checkpoint prepared as `feat(profile): send confirmation invitations`; audited Chatwoot `AgentBuilder`, Devise confirmation mail view/specs, and profile resend specs. GoChat now persists confirmation/reset tokens, sends fakeable Chatwoot-shaped confirmation or workspace invitation mail from profile resend, sends invitation mail for newly created invited agents, models `users.unconfirmed_email`, wires the account agent handler in bootstrap, and keeps SSO/SAML/LDAP/OIDC variants excluded. Focused profile/agent tests, combined handler/service/repository/router/migrate/app tests, full `go test ./...`, and `git diff --check` passed; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke.
- 2026-06-06: Documentation/tooling checkpoint prepared as `docs: land active parity follow-up`; records `682d0ab feat(profile): send confirmation invitations` as the latest implementation baseline, refreshes the B12 smoke readiness timestamp, reruns the placeholder audit classification, confirms route dump/parity commands still pass with `TOTAL: 905` and no route artifact changes, and leaves Phase 6 in Review with only webhook nil-handler fallback stubs. Verification for this docs checkpoint: `scripts/parity_frontend_smoke.sh --check`, placeholder `rg`, `go run ./cmd/dump_routes`, `go run ./cmd/route_parity`, and `git diff --check`; continue Phase 2/3 drift audit first unless the full live smoke stack is available.
- 2026-06-06: Documentation checkpoint prepared as `docs: track canned response parity`; worktree was clean at `75f0e80 feat(dashboard-apps): align chatwoot payloads`, and fresh reference/frontend inspection is now landed as P3.14. The tracker records canned response owner files, Chatwoot controller/model/schema/frontend references, raw payload and serializer contracts, `PATCH`/`search=`/account-scope/delete-status drift, required tests, and route artifact rules. Verification for this docs-only checkpoint: `git diff --check`; next slice is `feat(canned-responses): align chatwoot payloads`.
- 2026-06-06: P3.14 canned response checkpoint prepared as `feat(canned-responses): align chatwoot payloads`; audited Chatwoot canned responses controller/model/schema and reused dashboard API/store. GoChat now exposes no-trailing-slash index/create aliases and `PATCH` update, accepts raw and wrapped bodies, returns raw Chatwoot arrays/objects, supports `search=` with Chatwoot ranking, scopes mutations by account, and returns empty `200 OK` delete. Focused canned handler/service tests, combined handler/canned/router/route-parity tests, route dump/parity regeneration (`TOTAL: 910`, `358 exact`, `13 parameter-compatible`, `0 missing out of 371`), full `go test ./...`, and `git diff --check` passed; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke.
+4 -1
View File
@@ -156,6 +156,7 @@ GET /api/v1/accounts/:account_id/cache_keys
GET /api/v1/accounts/:account_id/campaigns
GET /api/v1/accounts/:account_id/campaigns/
GET /api/v1/accounts/:account_id/campaigns/:campaign_id
GET /api/v1/accounts/:account_id/canned_responses
GET /api/v1/accounts/:account_id/canned_responses/
GET /api/v1/accounts/:account_id/canned_responses/:id
GET /api/v1/accounts/:account_id/canned_responses/search
@@ -489,6 +490,7 @@ PATCH /api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits/:lim
PATCH /api/v1/accounts/:account_id/agents/:agent_id
PATCH /api/v1/accounts/:account_id/assignment_policies/:policy_id
PATCH /api/v1/accounts/:account_id/campaigns/:campaign_id
PATCH /api/v1/accounts/:account_id/canned_responses/:id
PATCH /api/v1/accounts/:account_id/channels/facebook_channel/:fb_id
PATCH /api/v1/accounts/:account_id/companies/:company_id
PATCH /api/v1/accounts/:account_id/contacts/:contact_id
@@ -569,6 +571,7 @@ POST /api/v1/accounts/:account_id/campaigns
POST /api/v1/accounts/:account_id/campaigns/
POST /api/v1/accounts/:account_id/campaigns/:campaign_id/start
POST /api/v1/accounts/:account_id/campaigns/:campaign_id/stop
POST /api/v1/accounts/:account_id/canned_responses
POST /api/v1/accounts/:account_id/canned_responses/
POST /api/v1/accounts/:account_id/captain/assistant_responses/
POST /api/v1/accounts/:account_id/captain/assistant_responses/process
@@ -905,4 +908,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: 907
TOTAL: 910
+2 -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: 357 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 370 tracked critical routes.
Summary: 358 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 371 tracked critical routes.
## Missing Critical Routes
@@ -247,6 +247,7 @@ These routes exist with equivalent method and path shape but different parameter
| GET | `/twitter/callback` | `/twitter/callback` | `twitter/callbacks#show` | `routes.rb:626` | exact |
| 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/canned_responses/:id` | `/api/v1/accounts/:account_id/canned_responses/:id` | `api/v1/accounts/canned_responses#update` | `routes.rb:114` | 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 |
+35 -3
View File
@@ -2,7 +2,6 @@ package canned
import (
"context"
"fmt"
"strings"
"gorm.io/gorm"
@@ -39,16 +38,45 @@ func (s *CannedResponseService) GetByID(ctx context.Context, id uint) (*CannedRe
return &resp, nil
}
// GetByAccountAndID retrieves a canned response scoped through its account.
func (s *CannedResponseService) GetByAccountAndID(ctx context.Context, accountID, id uint) (*CannedResponse, error) {
var resp CannedResponse
if err := s.db.DB().WithContext(ctx).
Where("account_id = ? AND id = ?", accountID, id).
First(&resp).Error; err != nil {
return nil, err
}
return &resp, nil
}
// Update updates a canned response.
func (s *CannedResponseService) Update(ctx context.Context, id uint, updates map[string]interface{}) error {
return s.db.DB().WithContext(ctx).Model(&CannedResponse{}).Where("id = ?", id).Updates(updates).Error
}
// UpdateByAccountAndID updates a canned response scoped through its account.
func (s *CannedResponseService) UpdateByAccountAndID(ctx context.Context, accountID, id uint, updates map[string]interface{}) error {
resp, err := s.GetByAccountAndID(ctx, accountID, id)
if err != nil {
return err
}
return s.db.DB().WithContext(ctx).Model(resp).Updates(updates).Error
}
// Delete soft-deletes a canned response.
func (s *CannedResponseService) Delete(ctx context.Context, id uint) error {
return s.db.DB().WithContext(ctx).Delete(&CannedResponse{}, id).Error
}
// DeleteByAccountAndID deletes a canned response scoped through its account.
func (s *CannedResponseService) DeleteByAccountAndID(ctx context.Context, accountID, id uint) error {
resp, err := s.GetByAccountAndID(ctx, accountID, id)
if err != nil {
return err
}
return s.db.DB().WithContext(ctx).Unscoped().Delete(resp).Error
}
// ListByAccount returns all canned responses for an account.
func (s *CannedResponseService) ListByAccount(ctx context.Context, accountID uint) ([]CannedResponse, error) {
var responses []CannedResponse
@@ -71,10 +99,14 @@ func (s *CannedResponseService) Search(ctx context.Context, accountID uint, quer
// Use a CASE-based ordering: short_code matches rank higher than content matches.
// This mirrors Chatwoot's order_by_search scope.
// The search is case-insensitive.
likePattern := fmt.Sprintf("%%%s%%", strings.ToLower(query))
lowerQuery := strings.ToLower(query)
prefixPattern := lowerQuery + "%"
likePattern := "%" + lowerQuery + "%"
err := s.db.DB().WithContext(ctx).
Select("canned_responses.*, CASE WHEN LOWER(short_code) LIKE ? THEN 1.0 WHEN LOWER(short_code) LIKE ? THEN 0.5 WHEN LOWER(content) LIKE ? THEN 0.2 ELSE 0 END AS search_rank", prefixPattern, likePattern, likePattern).
Where("account_id = ? AND (LOWER(short_code) LIKE ? OR LOWER(content) LIKE ?)", accountID, likePattern, likePattern).
Order(fmt.Sprintf("CASE WHEN LOWER(short_code) LIKE '%s' THEN 0 ELSE 1 END, short_code ASC", likePattern)).
Order("search_rank DESC").
Order("short_code ASC").
Find(&responses).Error
return responses, err
}
+120 -1
View File
@@ -97,6 +97,33 @@ func TestCannedResponseService_GetByID_NotFound(t *testing.T) {
}
}
func TestCannedResponseService_GetByAccountAndID(t *testing.T) {
provider := setupCannedTestDBProvider(t)
accountID, _ := seedTestAccount(provider.DB(), t)
otherAccount := &model.Account{Name: "Other Scoped Account"}
if err := provider.DB().Create(otherAccount).Error; err != nil {
t.Fatalf("failed to create other account: %v", err)
}
svc := NewCannedResponseService(provider)
cr := &CannedResponse{AccountID: accountID, ShortCode: "scoped", Content: "Scoped content"}
if err := svc.Create(context.Background(), cr); err != nil {
t.Fatalf("Create failed: %v", err)
}
found, err := svc.GetByAccountAndID(context.Background(), accountID, cr.ID)
if err != nil {
t.Fatalf("GetByAccountAndID failed: %v", err)
}
if found.ID != cr.ID {
t.Fatalf("expected ID %d, got %d", cr.ID, found.ID)
}
if _, err := svc.GetByAccountAndID(context.Background(), otherAccount.ID, cr.ID); err == nil {
t.Fatal("expected cross-account lookup to fail")
}
}
func TestCannedResponseService_Update(t *testing.T) {
provider := setupCannedTestDBProvider(t)
accountID, _ := seedTestAccount(provider.DB(), t)
@@ -131,6 +158,36 @@ func TestCannedResponseService_Update(t *testing.T) {
}
}
func TestCannedResponseService_UpdateByAccountAndID(t *testing.T) {
provider := setupCannedTestDBProvider(t)
accountID, _ := seedTestAccount(provider.DB(), t)
otherAccount := &model.Account{Name: "Other Update Account"}
if err := provider.DB().Create(otherAccount).Error; err != nil {
t.Fatalf("failed to create other account: %v", err)
}
svc := NewCannedResponseService(provider)
cr := &CannedResponse{AccountID: accountID, ShortCode: "account_update", Content: "Original"}
if err := svc.Create(context.Background(), cr); err != nil {
t.Fatalf("Create failed: %v", err)
}
if err := svc.UpdateByAccountAndID(context.Background(), otherAccount.ID, cr.ID, map[string]interface{}{"content": "Wrong"}); err == nil {
t.Fatal("expected cross-account update to fail")
}
if err := svc.UpdateByAccountAndID(context.Background(), accountID, cr.ID, map[string]interface{}{"content": "Updated"}); err != nil {
t.Fatalf("UpdateByAccountAndID failed: %v", err)
}
found, err := svc.GetByAccountAndID(context.Background(), accountID, cr.ID)
if err != nil {
t.Fatalf("GetByAccountAndID after update failed: %v", err)
}
if found.Content != "Updated" {
t.Fatalf("expected updated content, got %q", found.Content)
}
}
func TestCannedResponseService_Delete(t *testing.T) {
provider := setupCannedTestDBProvider(t)
accountID, _ := seedTestAccount(provider.DB(), t)
@@ -156,6 +213,40 @@ func TestCannedResponseService_Delete(t *testing.T) {
}
}
func TestCannedResponseService_DeleteByAccountAndID(t *testing.T) {
provider := setupCannedTestDBProvider(t)
accountID, _ := seedTestAccount(provider.DB(), t)
otherAccount := &model.Account{Name: "Other Delete Account"}
if err := provider.DB().Create(otherAccount).Error; err != nil {
t.Fatalf("failed to create other account: %v", err)
}
svc := NewCannedResponseService(provider)
cr := &CannedResponse{AccountID: accountID, ShortCode: "account_delete", Content: "Delete"}
if err := svc.Create(context.Background(), cr); err != nil {
t.Fatalf("Create failed: %v", err)
}
if err := svc.DeleteByAccountAndID(context.Background(), otherAccount.ID, cr.ID); err == nil {
t.Fatal("expected cross-account delete to fail")
}
if _, err := svc.GetByAccountAndID(context.Background(), accountID, cr.ID); err != nil {
t.Fatalf("record should still exist after failed cross-account delete: %v", err)
}
if err := svc.DeleteByAccountAndID(context.Background(), accountID, cr.ID); err != nil {
t.Fatalf("DeleteByAccountAndID failed: %v", err)
}
if _, err := svc.GetByAccountAndID(context.Background(), accountID, cr.ID); err == nil {
t.Fatal("expected account-scoped lookup to fail after delete")
}
recreated := &CannedResponse{AccountID: accountID, ShortCode: "account_delete", Content: "Recreated"}
if err := svc.Create(context.Background(), recreated); err != nil {
t.Fatalf("expected hard-deleted short_code to be reusable: %v", err)
}
}
func TestCannedResponseService_ListByAccount(t *testing.T) {
provider := setupCannedTestDBProvider(t)
accountID, _ := seedTestAccount(provider.DB(), t)
@@ -266,6 +357,34 @@ func TestCannedResponseService_Search(t *testing.T) {
}
}
func TestCannedResponseService_Search_RanksLikeChatwoot(t *testing.T) {
provider := setupCannedTestDBProvider(t)
accountID, _ := seedTestAccount(provider.DB(), t)
svc := NewCannedResponseService(provider)
items := []CannedResponse{
{AccountID: accountID, ShortCode: "body_only", Content: "Please mention help in the body"},
{AccountID: accountID, ShortCode: "say_help", Content: "Generic"},
{AccountID: accountID, ShortCode: "help_start", Content: "Generic"},
}
for i := range items {
if err := svc.Create(context.Background(), &items[i]); err != nil {
t.Fatalf("Create #%d failed: %v", i, err)
}
}
results, err := svc.Search(context.Background(), accountID, "help")
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if len(results) != 3 {
t.Fatalf("expected 3 results, got %d", len(results))
}
if results[0].ShortCode != "help_start" || results[1].ShortCode != "say_help" || results[2].ShortCode != "body_only" {
t.Fatalf("unexpected ranking: %s, %s, %s", results[0].ShortCode, results[1].ShortCode, results[2].ShortCode)
}
}
func TestCannedResponseService_GetByShortCode(t *testing.T) {
provider := setupCannedTestDBProvider(t)
accountID, _ := seedTestAccount(provider.DB(), t)
@@ -296,4 +415,4 @@ func TestCannedResponseService_GetByShortCode(t *testing.T) {
if err == nil {
t.Fatal("expected error for non-existent short_code, got nil")
}
}
}
@@ -1,7 +1,9 @@
package v1
import (
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/canned"
@@ -15,11 +17,72 @@ type CannedResponseHandler struct {
svc *canned.CannedResponseService
}
type cannedResponseInput struct {
ShortCode *string `json:"short_code"`
Content *string `json:"content"`
}
type cannedResponsePayload struct {
ID uint `json:"id"`
AccountID uint `json:"account_id"`
ShortCode string `json:"short_code"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// NewCannedResponseHandler creates a new CannedResponseHandler.
func NewCannedResponseHandler(svc *canned.CannedResponseService) *CannedResponseHandler {
return &CannedResponseHandler{svc: svc}
}
func bindCannedResponseInput(c *gin.Context) (cannedResponseInput, error) {
var body map[string]json.RawMessage
if err := c.ShouldBindJSON(&body); err != nil {
return cannedResponseInput{}, err
}
if raw, ok := body["canned_response"]; ok {
var wrapped cannedResponseInput
if err := json.Unmarshal(raw, &wrapped); err != nil {
return cannedResponseInput{}, err
}
return wrapped, nil
}
var input cannedResponseInput
if raw, ok := body["short_code"]; ok {
if err := json.Unmarshal(raw, &input.ShortCode); err != nil {
return cannedResponseInput{}, err
}
}
if raw, ok := body["content"]; ok {
if err := json.Unmarshal(raw, &input.Content); err != nil {
return cannedResponseInput{}, err
}
}
return input, nil
}
func serializeCannedResponse(cr canned.CannedResponse) cannedResponsePayload {
return cannedResponsePayload{
ID: cr.ID,
AccountID: cr.AccountID,
ShortCode: cr.ShortCode,
Content: cr.Content,
CreatedAt: cr.CreatedAt,
UpdatedAt: cr.UpdatedAt,
}
}
func serializeCannedResponses(responses []canned.CannedResponse) []cannedResponsePayload {
payload := make([]cannedResponsePayload, 0, len(responses))
for _, cr := range responses {
payload = append(payload, serializeCannedResponse(cr))
}
return payload
}
// Create creates a new canned response.
// POST /api/v1/accounts/:account_id/canned_responses
// Reference: Chatwoot CannedResponsesController#create
@@ -30,23 +93,20 @@ func (h *CannedResponseHandler) Create(c *gin.Context) {
return
}
// Chatwoot: params.require(:canned_response) → {"canned_response": {...}}
var wrapper struct {
CannedResponse struct {
ShortCode string `json:"short_code" binding:"required"`
Content string `json:"content" binding:"required"`
} `json:"canned_response"`
}
if err := c.ShouldBindJSON(&wrapper); err != nil {
req, err := bindCannedResponseInput(c)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
req := wrapper.CannedResponse
if req.ShortCode == nil || *req.ShortCode == "" || req.Content == nil || *req.Content == "" {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "short_code and content are required")
return
}
cr := &canned.CannedResponse{
AccountID: accountID,
ShortCode: req.ShortCode,
Content: req.Content,
ShortCode: *req.ShortCode,
Content: *req.Content,
}
if err := h.svc.Create(c.Request.Context(), cr); err != nil {
@@ -55,7 +115,7 @@ func (h *CannedResponseHandler) Create(c *gin.Context) {
return
}
response.Created(c, cr)
c.JSON(http.StatusOK, serializeCannedResponse(*cr))
}
// Get retrieves a canned response by ID.
@@ -67,14 +127,19 @@ func (h *CannedResponseHandler) Get(c *gin.Context) {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
return
}
accountID, err := parseUintParam(c, "account_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
cr, svcErr := h.svc.GetByID(c.Request.Context(), id)
cr, svcErr := h.svc.GetByAccountAndID(c.Request.Context(), accountID, id)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
response.OK(c, cr)
c.JSON(http.StatusOK, serializeCannedResponse(*cr))
}
// Update updates a canned response.
@@ -86,26 +151,24 @@ func (h *CannedResponseHandler) Update(c *gin.Context) {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
return
}
// Chatwoot: params.require(:canned_response) → {"canned_response": {...}}
var wrapper struct {
CannedResponse struct {
ShortCode string `json:"short_code"`
Content string `json:"content"`
} `json:"canned_response"`
accountID, err := parseUintParam(c, "account_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
if err := c.ShouldBindJSON(&wrapper); err != nil {
req, err := bindCannedResponseInput(c)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
req := wrapper.CannedResponse
updates := map[string]interface{}{}
if req.ShortCode != "" {
updates["short_code"] = req.ShortCode
if req.ShortCode != nil {
updates["short_code"] = *req.ShortCode
}
if req.Content != "" {
updates["content"] = req.Content
if req.Content != nil {
updates["content"] = *req.Content
}
if len(updates) == 0 {
@@ -113,20 +176,19 @@ func (h *CannedResponseHandler) Update(c *gin.Context) {
return
}
if svcErr := h.svc.Update(c.Request.Context(), id, updates); svcErr != nil {
if svcErr := h.svc.UpdateByAccountAndID(c.Request.Context(), accountID, id, updates); svcErr != nil {
applogger.L().Errorf("Update canned response: %v", svcErr)
handleServiceError(c, svcErr)
return
}
// Fetch updated record to return
cr, svcErr := h.svc.GetByID(c.Request.Context(), id)
cr, svcErr := h.svc.GetByAccountAndID(c.Request.Context(), accountID, id)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
response.OK(c, cr)
c.JSON(http.StatusOK, serializeCannedResponse(*cr))
}
// Delete soft-deletes a canned response.
@@ -138,14 +200,19 @@ func (h *CannedResponseHandler) Delete(c *gin.Context) {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
return
}
accountID, err := parseUintParam(c, "account_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
if svcErr := h.svc.Delete(c.Request.Context(), id); svcErr != nil {
if svcErr := h.svc.DeleteByAccountAndID(c.Request.Context(), accountID, id); svcErr != nil {
applogger.L().Errorf("Delete canned response: %v", svcErr)
handleServiceError(c, svcErr)
return
}
response.NoContent(c)
c.Status(http.StatusOK)
}
// List returns all canned responses for an account.
@@ -158,17 +225,17 @@ func (h *CannedResponseHandler) List(c *gin.Context) {
return
}
// If a search query is provided, use Search instead of ListByAccount
searchQuery := c.Query("q")
searchQuery := c.Query("search")
if searchQuery == "" {
searchQuery = c.Query("q")
}
if searchQuery != "" {
responses, svcErr := h.svc.Search(c.Request.Context(), accountID, searchQuery)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
response.OK(c, gin.H{
"canned_responses": responses,
})
c.JSON(http.StatusOK, serializeCannedResponses(responses))
return
}
@@ -178,9 +245,7 @@ func (h *CannedResponseHandler) List(c *gin.Context) {
return
}
response.OK(c, gin.H{
"canned_responses": responses,
})
c.JSON(http.StatusOK, serializeCannedResponses(responses))
}
// Search performs a ranked search over canned responses.
@@ -193,14 +258,15 @@ func (h *CannedResponseHandler) Search(c *gin.Context) {
return
}
searchQuery := c.Query("q")
searchQuery := c.Query("search")
if searchQuery == "" {
searchQuery = c.Query("q")
}
responses, svcErr := h.svc.Search(c.Request.Context(), accountID, searchQuery)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
response.OK(c, gin.H{
"canned_responses": responses,
})
c.JSON(http.StatusOK, serializeCannedResponses(responses))
}
@@ -2,6 +2,7 @@ package v1
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
@@ -58,6 +59,30 @@ func TestCannedResponseHandlerSuite(t *testing.T) {
suite.Run(t, new(CannedResponseHandlerTestSuite))
}
func (s *CannedResponseHandlerTestSuite) SetupTest() {
s.Require().NoError(s.db.Exec("DELETE FROM canned_responses").Error)
}
func (s *CannedResponseHandlerTestSuite) router() *gin.Engine {
r := gin.New()
r.GET("/api/v1/accounts/:account_id/canned_responses", s.handler.List)
r.GET("/api/v1/accounts/:account_id/canned_responses/", s.handler.List)
r.POST("/api/v1/accounts/:account_id/canned_responses", s.handler.Create)
r.POST("/api/v1/accounts/:account_id/canned_responses/", s.handler.Create)
r.GET("/api/v1/accounts/:account_id/canned_responses/search", s.handler.Search)
r.GET("/api/v1/accounts/:account_id/canned_responses/:id", s.handler.Get)
r.PATCH("/api/v1/accounts/:account_id/canned_responses/:id", s.handler.Update)
r.PUT("/api/v1/accounts/:account_id/canned_responses/:id", s.handler.Update)
r.DELETE("/api/v1/accounts/:account_id/canned_responses/:id", s.handler.Delete)
return r
}
func (s *CannedResponseHandlerTestSuite) seedCannedResponse(shortCode, content string) *canned.CannedResponse {
cr := &canned.CannedResponse{AccountID: s.account.ID, ShortCode: shortCode, Content: content}
s.Require().NoError(s.db.Create(cr).Error)
return cr
}
func (s *CannedResponseHandlerTestSuite) TestList_BadRequest_InvalidAccountID() {
r := gin.New()
r.GET("/api/v1/accounts/:account_id/canned_responses", s.handler.List)
@@ -70,14 +95,114 @@ func (s *CannedResponseHandlerTestSuite) TestList_BadRequest_InvalidAccountID()
}
func (s *CannedResponseHandlerTestSuite) TestList_Success() {
r := gin.New()
r.GET("/api/v1/accounts/:account_id/canned_responses", s.handler.List)
s.seedCannedResponse("hello", "Hello there")
r := s.router()
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/canned_responses", s.account.ID), nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var payload []map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
s.Require().Len(payload, 1)
assert.Equal(s.T(), "hello", payload[0]["short_code"])
assert.NotContains(s.T(), payload[0], "deleted_at")
}
func (s *CannedResponseHandlerTestSuite) TestList_SearchParamReturnsRawRankedArray() {
s.seedCannedResponse("hey_start", "Generic content")
s.seedCannedResponse("say_hey", "Generic content")
s.seedCannedResponse("body_match", "Please say hey to the customer")
r := s.router()
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/canned_responses?search=hey", s.account.ID), nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var payload []map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
s.Require().Len(payload, 3)
assert.Equal(s.T(), "hey_start", payload[0]["short_code"])
assert.Equal(s.T(), "say_hey", payload[1]["short_code"])
assert.Equal(s.T(), "body_match", payload[2]["short_code"])
}
func (s *CannedResponseHandlerTestSuite) TestCreate_RawFrontendBodyReturnsRawPayload() {
r := s.router()
w := httptest.NewRecorder()
body := bytes.NewBufferString(`{"short_code":"welcome","content":"Welcome!"}`)
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/canned_responses", s.account.ID), body)
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var payload map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
assert.Equal(s.T(), "welcome", payload["short_code"])
assert.Equal(s.T(), "Welcome!", payload["content"])
assert.Equal(s.T(), float64(s.account.ID), payload["account_id"])
assert.NotContains(s.T(), payload, "success")
assert.NotContains(s.T(), payload, "data")
}
func (s *CannedResponseHandlerTestSuite) TestUpdate_PatchRawBodyIsAccountScoped() {
cr := s.seedCannedResponse("old", "Old content")
r := s.router()
w := httptest.NewRecorder()
body := bytes.NewBufferString(`{"short_code":"new","content":"New content"}`)
req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/canned_responses/%d", s.account.ID, cr.ID), body)
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var payload map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
assert.Equal(s.T(), "new", payload["short_code"])
assert.Equal(s.T(), "New content", payload["content"])
}
func (s *CannedResponseHandlerTestSuite) TestUpdate_CrossAccountReturnsNotFound() {
cr := s.seedCannedResponse("private", "Private content")
otherAccount := &model.Account{Name: "other-canned-account"}
s.Require().NoError(s.db.Create(otherAccount).Error)
r := s.router()
w := httptest.NewRecorder()
body := bytes.NewBufferString(`{"content":"Nope"}`)
req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/canned_responses/%d", otherAccount.ID, cr.ID), body)
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
func (s *CannedResponseHandlerTestSuite) TestDelete_ReturnsOKEmptyAndScopesAccount() {
cr := s.seedCannedResponse("delete_me", "Delete me")
r := s.router()
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/canned_responses/%d", s.account.ID, cr.ID), nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
assert.Empty(s.T(), w.Body.String())
}
func (s *CannedResponseHandlerTestSuite) TestDelete_CrossAccountReturnsNotFound() {
cr := s.seedCannedResponse("private_delete", "Private content")
otherAccount := &model.Account{Name: "other-delete-account"}
s.Require().NoError(s.db.Create(otherAccount).Error)
r := s.router()
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/canned_responses/%d", otherAccount.ID, cr.ID), nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
func (s *CannedResponseHandlerTestSuite) TestCreate_BadRequest_EmptyBody() {
@@ -135,4 +260,4 @@ func (s *CannedResponseHandlerTestSuite) TestSearch_BadRequest_InvalidAccountID(
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
}
+4 -1
View File
@@ -1639,12 +1639,15 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
// Reference: Chatwoot namespace :canned_responses
cannedResponses := accountScoped.Group("/canned_responses")
{
cannedResponses.GET("", h.CannedResponse.List)
cannedResponses.GET("/", h.CannedResponse.List)
cannedResponses.POST("", h.CannedResponse.Create)
cannedResponses.POST("/", h.CannedResponse.Create)
cannedResponses.GET("/search", h.CannedResponse.Search)
cannedResponses.GET("/:id", h.CannedResponse.Get)
cannedResponses.PATCH("/:id", h.CannedResponse.Update)
cannedResponses.PUT("/:id", h.CannedResponse.Update)
cannedResponses.DELETE("/:id", h.CannedResponse.Delete)
cannedResponses.GET("/search", h.CannedResponse.Search)
}
// Integration hooks (Chatwoot: resources :hooks)