feat(dashboard-apps): align chatwoot payloads

This commit is contained in:
2026-06-06 13:03:16 +08:00
parent 11e5537f9b
commit 75f0e809a5
9 changed files with 312 additions and 71 deletions
+7
View File
@@ -73,6 +73,13 @@ var criticalRoutes = []route{
{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"},
{Method: "GET", Path: "/api/v1/accounts/:account_id/dashboard_apps", Controller: "api/v1/accounts/dashboard_apps#index", Source: "routes.rb:130"},
{Method: "POST", Path: "/api/v1/accounts/:account_id/dashboard_apps", Controller: "api/v1/accounts/dashboard_apps#create", Source: "routes.rb:130"},
{Method: "GET", Path: "/api/v1/accounts/:account_id/dashboard_apps/:id", Controller: "api/v1/accounts/dashboard_apps#show", Source: "routes.rb:130"},
{Method: "PATCH", Path: "/api/v1/accounts/:account_id/dashboard_apps/:id", Controller: "api/v1/accounts/dashboard_apps#update", Source: "routes.rb:130"},
{Method: "PUT", Path: "/api/v1/accounts/:account_id/dashboard_apps/:id", Controller: "api/v1/accounts/dashboard_apps#update", Source: "routes.rb:130"},
{Method: "DELETE", Path: "/api/v1/accounts/:account_id/dashboard_apps/:id", Controller: "api/v1/accounts/dashboard_apps#destroy", Source: "routes.rb:130"},
{Method: "GET", Path: "/api/v1/accounts/:account_id/conversations/", Controller: "api/v1/accounts/conversations#index", Source: "routes.rb:134"},
{Method: "POST", Path: "/api/v1/accounts/:account_id/conversations/", Controller: "api/v1/accounts/conversations#create", Source: "routes.rb:134"},
{Method: "GET", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id", Controller: "api/v1/accounts/conversations#show", Source: "routes.rb:134"},
+14 -10
View File
@@ -49,14 +49,14 @@ Hermes task landing checklist:
## Current Baseline
- Current tracking checkpoint: 2026-06-06 account Dyte integration checkpoint, prepared as `feat(integrations): align dyte meeting routes`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(integrations): align dyte meeting routes`.
- Current tracking checkpoint: 2026-06-06 dashboard apps checkpoint, prepared as `feat(dashboard-apps): align chatwoot payloads`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(dashboard-apps): align chatwoot payloads`.
- Latest documentation/tooling checkpoint: this checkpoint, prepared as `docs: land active parity follow-up`; this document is the active follow-up plan and supersedes `.hermes/plans/*`.
- Worktree status at this implementation checkpoint: account-scoped Dyte meeting routes from `reference/chatwoot/config/routes.rb:357-358` are implemented for the reused dashboard Dyte client. `POST /api/v1/accounts/:account_id/integrations/dyte/create_a_meeting` creates a Chatwoot `integrations` message with Dyte meeting content attributes, and `POST /api/v1/accounts/:account_id/integrations/dyte/add_participant_to_meeting` returns the Dyte participant token payload while rejecting non-integration messages with the reference error. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. The next active slice remains Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or a fresh reference/smoke finding.
- Worktree status at this implementation 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. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. The next active slice remains 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 parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
- Tracked frontend-critical route audit covers 364 Chatwoot routes: 355 exact, 0 method-compatible, 9 parameter-compatible, 0 missing. The 9 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits plus the public article `.md`/`.png` suffixes served through the same external article route dispatcher.
- 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.
- `/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.
@@ -116,7 +116,7 @@ Open work after the current checkpoint:
| Phase 5 jobs | Keep B9.3 in Review and open only evidence-backed drift slices for new async gaps. | `Phase 5: Background Jobs And Integrations` | Automation tests prove remaining action side effects and reference inspection proves no delayed action params remain. |
| B12 smoke | Run optional live API/browser/enterprise smoke in a full PostgreSQL/Redis/Meilisearch/Vite environment and convert failures into named slices. | `B12 reused frontend verification breakdown` | `docs/parity/frontend_smoke_report.md` records checked pass/fail results and maps failures to slices. |
| P3.7 help center | Portal, category, article route/payload parity, and enterprise article bulk translation are in Review for the inspected dashboard client paths. | `Open Checkpoint Contracts`, `Phase 3` | Reused Chatwoot help-center dashboard clients can call portal/category/article APIs and bulk article translation without adapters. |
| Phase 2/3 drift | Help-center portal/category/article routes from `routes.rb:385-404` and account Dyte integration routes from `routes.rb:357-358` are now in the tracked critical set; expand the next route/serializer fixtures only from B12 failures or fresh reference/frontend inspection. | `Phase 2`, `Phase 3`, `docs/parity/` | Route parity remains 0 missing for tracked frontend routes; serializers have reference fixtures. |
| Phase 2/3 drift | Help-center portal/category/article routes from `routes.rb:385-404`, account Dyte integration routes from `routes.rb:357-358`, and dashboard app routes from `routes.rb:130` are now in the tracked critical set; expand the next route/serializer fixtures only from B12 failures or fresh reference/frontend inspection. | `Phase 2`, `Phase 3`, `docs/parity/` | Route parity remains 0 missing for tracked frontend routes; serializers have reference fixtures. |
| Phase 6 placeholders | Re-run placeholder audit and burn down any frontend-reachable stub. | `Phase 6: Core Product Placeholder Burn-down` | Stub list has no reused-frontend critical path without a named owner. |
## Execution Snapshot
@@ -139,16 +139,17 @@ 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 364-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`, and account Dyte routes from `routes.rb:357-358` 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, 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 account Dyte 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 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. |
| 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 | 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. |
| 9 | 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. |
| 10 | 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. |
| 11 | 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. |
| 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 | 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. |
| 10 | 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. |
| 11 | 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. |
| 12 | 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
@@ -172,6 +173,7 @@ These rows are the executable development plan from this point forward. A checkp
| 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. |
| 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. |
| 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. |
@@ -213,6 +215,7 @@ This ledger records the committed parity checkpoints that future slices should b
| Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- |
| `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. |
| `feat(profile): send confirmation invitations` | Advances P3.2a invitation/confirmation mail parity by matching Chatwoot `AgentBuilder`, Devise confirmation instructions view/specs, and profile resend controller behavior for non-SSO flows. GoChat now has a fakeable/environment SMTP confirmation mail boundary; profile resend persists confirmation or reset-password tokens and sends normal confirmation or workspace invitation payloads; confirmed resends remain no-op; newly created invited agents send invitation mail; `users.unconfirmed_email` is modeled for email-update branch routing; and bootstrap wires the account agent handler plus confirmation mailers. | `go test ./internal/handler/api/v1 -run 'ProfileHandlerSuite/TestResendConfirmation\|AgentHandlerSuite/TestCreateAgent' -count=1`; `go test ./internal/service ./internal/repository ./cmd/migrate ./internal/app -run 'Profile\|Agent\|App\|Migrate' -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/repository ./internal/router ./cmd/migrate ./internal/app -count=1`; full `go test ./...`; `git diff --check`. No route artifacts changed. | P3.2a moves to Review for current non-SSO reference evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
| `docs: land active parity follow-up` | Documentation/tooling checkpoint requested before continuing implementation. It records the committed `682d0ab feat(profile): send confirmation invitations` baseline, refreshes the Phase 6 placeholder and B12 readiness evidence, and keeps the next executable queue limited to Phase 2/3 drift audit, optional B12 live smoke, or fresh reference/smoke findings. | `bash -n scripts/parity_frontend_smoke.sh`; `scripts/parity_frontend_smoke.sh --check`; `rg -n "TODO\|placeholder\|chatwootParityStub\|stub\|not implemented\|unsupported" internal/handler internal/service internal/router -g'*.go'`; `go run ./cmd/dump_routes`; `go run ./cmd/route_parity`; `git diff --check`. No route artifacts changed; route dump remains `TOTAL: 905`. | Continue Phase 2/3 drift audit first unless the full B12 live stack is available. Keep Phase 6 in Review and reopen only from fresh frontend-reachable placeholder evidence. |
@@ -2207,6 +2210,7 @@ Verification milestone gates:
## Progress Log
- 2026-06-06: P3.13 dashboard apps checkpoint prepared as `feat(dashboard-apps): align chatwoot payloads`; audited Chatwoot `routes.rb:130`, `DashboardAppsController`, dashboard app Jbuilder views, `DashboardApp` content schema, reused dashboard `api/dashboardApps.js`, Vuex `dashboardApps` store, and DashboardApps modal/index components. Dashboard app routes are now tracked, create/update accept raw frontend bodies as well as `{ dashboard_app: ... }`, create returns raw `200 OK` payloads, list/show/create/update return only `id`, `title`, `content`, and `created_at`, show/update/delete are account-scoped, delete returns `204`, and content validation rejects empty arrays, non-frame widgets, and non-HTTP(S) URLs. 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; route dump remains `TOTAL: 907` and tracked route parity is `357 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 370`.
- 2026-06-06: P3.12 account Dyte integration checkpoint prepared as `feat(integrations): align dyte meeting routes`; audited Chatwoot `routes.rb:357-358`, `Api::V1::Accounts::Integrations::DyteController`, `Integrations::Dyte::ProcessorService`, `lib/dyte.rb`, reused dashboard `api/integrations/dyte.js`, `VideoCallButton.vue`, and the Dyte message bubble. Account Dyte create-meeting/add-participant routes are now registered and tracked, create-meeting resolves conversations by display ID, checks inbox access, loads account hook credentials, creates an outgoing `integrations` message with `content_attributes.type=dyte`, and returns the normal message serializer; add-participant rejects non-integration messages with the reference error and returns the Dyte participant token payload through a fakeable backend. Focused Dyte service/handler tests, combined handler/service/router/app/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed; route dump is `TOTAL: 907` and tracked route parity is `355 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 364`.
- 2026-06-06: P3.2 account-user inviter checkpoint prepared as `feat(account-users): align inviter id`; audited Chatwoot `AccountUser` schema, `AgentBuilder#create_account_user`, `api/v1/models/_user.json.jbuilder`, and enterprise `Audit::AccountUser`. GoChat now persists invitation source through the Chatwoot `inviter_id` column/JSON name instead of the local `invited_by` column, copies existing `invited_by` values forward in migration `000031`, emits account-user lifecycle events with `inviter_id`, and verifies agent creation stores the current user as inviter without leaking inviter fields through the `_agent` serializer. Focused model/agent/service/repository/migration tests, full `go test ./...`, and `git diff --check` passed. Continue remaining P3.2 invitation mail/confirmation drift or B12 live smoke.
- 2026-06-06: P3.1 profile HMAC checkpoint prepared as `feat(profile): expose hmac identifier`; audited Chatwoot `app/views/api/v1/models/_user.json.jbuilder`, `UserAttributeHelpers#hmac_identifier`, `spec/models/user_spec.rb`, and reused frontend `scriptHelpers.js`. Profile responses now omit `hmac_identifier` when `CHATWOOT_INBOX_HMAC_KEY` is absent and expose `OpenSSL::HMAC.hexdigest('sha256', key, email)` compatible values when the installation config exists, letting the reused widget script helper send `identifier_hash` without adapter code. Focused profile handler tests, service compile test, full `go test ./...`, and `git diff --check` passed. Continue remaining P3.2 invitations drift or B12 live smoke.
+7 -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: 355 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 364 tracked critical routes.
Summary: 357 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 370 tracked critical routes.
## Missing Critical Routes
@@ -31,13 +31,17 @@ These routes exist with equivalent method and path shape but different parameter
| --- | --- | --- | --- | --- | --- |
| DELETE | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/inbox_limits/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits/:limit_id` | `api/v1/accounts/agent_capacity_policies/inbox_limits#destroy` | `routes.rb:126` | parameter-compatible |
| DELETE | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/users/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/users/:user_id` | `api/v1/accounts/agent_capacity_policies/users#destroy` | `routes.rb:125` | parameter-compatible |
| DELETE | `/api/v1/accounts/:account_id/dashboard_apps/:id` | `/api/v1/accounts/:account_id/dashboard_apps/:dashboard_app_id` | `api/v1/accounts/dashboard_apps#destroy` | `routes.rb:130` | parameter-compatible |
| GET | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/users` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/users` | `api/v1/accounts/agent_capacity_policies/users#index` | `routes.rb:125` | parameter-compatible |
| GET | `/api/v1/accounts/:account_id/dashboard_apps/:id` | `/api/v1/accounts/:account_id/dashboard_apps/:dashboard_app_id` | `api/v1/accounts/dashboard_apps#show` | `routes.rb:130` | parameter-compatible |
| GET | `/hc/:slug/articles/:article_slug.md` | `/hc/:slug/articles/:article_slug` | `public/api/v1/portals/articles#show_markdown` | `routes.rb:599` | parameter-compatible |
| GET | `/hc/:slug/articles/:article_slug.png` | `/hc/:slug/articles/:article_slug` | `public/api/v1/portals/articles#tracking_pixel` | `routes.rb:598` | parameter-compatible |
| PATCH | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/inbox_limits/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits/:limit_id` | `api/v1/accounts/agent_capacity_policies/inbox_limits#update` | `routes.rb:126` | parameter-compatible |
| PATCH | `/api/v1/accounts/:account_id/dashboard_apps/:id` | `/api/v1/accounts/:account_id/dashboard_apps/:dashboard_app_id` | `api/v1/accounts/dashboard_apps#update` | `routes.rb:130` | parameter-compatible |
| POST | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/inbox_limits` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits` | `api/v1/accounts/agent_capacity_policies/inbox_limits#create` | `routes.rb:126` | parameter-compatible |
| POST | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/users` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/users` | `api/v1/accounts/agent_capacity_policies/users#create` | `routes.rb:125` | parameter-compatible |
| PUT | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/inbox_limits/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits/:limit_id` | `api/v1/accounts/agent_capacity_policies/inbox_limits#update` | `routes.rb:126` | parameter-compatible |
| PUT | `/api/v1/accounts/:account_id/dashboard_apps/:id` | `/api/v1/accounts/:account_id/dashboard_apps/:dashboard_app_id` | `api/v1/accounts/dashboard_apps#update` | `routes.rb:130` | parameter-compatible |
## Exact Critical Routes
@@ -150,6 +154,7 @@ These routes exist with equivalent method and path shape but different parameter
| GET | `/api/v1/accounts/:account_id/custom_filters/` | `/api/v1/accounts/:account_id/custom_filters/` | `api/v1/accounts/custom_filters#index` | `routes.rb:251` | exact |
| GET | `/api/v1/accounts/:account_id/custom_filters/:id` | `/api/v1/accounts/:account_id/custom_filters/:id` | `api/v1/accounts/custom_filters#show` | `routes.rb:251` | exact |
| GET | `/api/v1/accounts/:account_id/custom_roles/` | `/api/v1/accounts/:account_id/custom_roles/` | `api/v1/accounts/custom_roles#index` | `routes.rb:122` | exact |
| GET | `/api/v1/accounts/:account_id/dashboard_apps` | `/api/v1/accounts/:account_id/dashboard_apps` | `api/v1/accounts/dashboard_apps#index` | `routes.rb:130` | exact |
| GET | `/api/v1/accounts/:account_id/inbox_members/:inbox_id` | `/api/v1/accounts/:account_id/inbox_members/:inbox_id` | `api/v1/accounts/inbox_members#show` | `routes.rb:275` | exact |
| GET | `/api/v1/accounts/:account_id/inboxes/` | `/api/v1/accounts/:account_id/inboxes/` | `api/v1/accounts/inboxes#index` | `routes.rb:252` | exact |
| GET | `/api/v1/accounts/:account_id/inboxes/:inbox_id` | `/api/v1/accounts/:account_id/inboxes/:inbox_id` | `api/v1/accounts/inboxes#show` | `routes.rb:252` | exact |
@@ -307,6 +312,7 @@ These routes exist with equivalent method and path shape but different parameter
| POST | `/api/v1/accounts/:account_id/conversations/filter` | `/api/v1/accounts/:account_id/conversations/filter` | `api/v1/accounts/conversations#filter` | `routes.rb:139` | exact |
| POST | `/api/v1/accounts/:account_id/custom_attribute_definitions/` | `/api/v1/accounts/:account_id/custom_attribute_definitions/` | `api/v1/accounts/custom_attribute_definitions#create` | `routes.rb:250` | exact |
| POST | `/api/v1/accounts/:account_id/custom_filters/` | `/api/v1/accounts/:account_id/custom_filters/` | `api/v1/accounts/custom_filters#create` | `routes.rb:251` | exact |
| POST | `/api/v1/accounts/:account_id/dashboard_apps` | `/api/v1/accounts/:account_id/dashboard_apps` | `api/v1/accounts/dashboard_apps#create` | `routes.rb:130` | exact |
| POST | `/api/v1/accounts/:account_id/inbox_members/` | `/api/v1/accounts/:account_id/inbox_members/` | `api/v1/accounts/inbox_members#create` | `routes.rb:275` | exact |
| POST | `/api/v1/accounts/:account_id/inboxes/` | `/api/v1/accounts/:account_id/inboxes/` | `api/v1/accounts/inboxes#create` | `routes.rb:252` | exact |
| POST | `/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#create` | `routes.rb:311` | exact |
+123 -28
View File
@@ -1,13 +1,18 @@
package v1
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/service"
applogger "github.com/gochat/gochat/pkg/logger"
"github.com/gochat/gochat/pkg/response"
pkgvalidator "github.com/gochat/gochat/pkg/validator"
)
// DashboardAppHandler handles DashboardApp CRUD endpoints.
@@ -21,6 +26,13 @@ type DashboardAppHandler struct {
svc *service.DashboardAppService
}
type dashboardAppPayload struct {
ID uint `json:"id"`
Title string `json:"title"`
Content json.RawMessage `json:"content"`
CreatedAt time.Time `json:"created_at"`
}
func NewDashboardAppHandler(svc *service.DashboardAppService) *DashboardAppHandler {
return &DashboardAppHandler{svc: svc}
}
@@ -37,26 +49,24 @@ func (h *DashboardAppHandler) Create(c *gin.Context) {
userID := getUserID(c)
userIDPtr := &userID
var wrapper service.DashboardAppCreateWrapper
if err := c.ShouldBindJSON(&wrapper); err != nil {
req, err := bindDashboardAppCreate(c)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
req := wrapper.DashboardApp
if err := pkgvalidator.ValidateStruct(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
if req.Title == "" {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "title is required")
return
}
app, err := h.svc.Create(c.Request.Context(), accountID, userIDPtr, &req)
app, err := h.svc.Create(c.Request.Context(), accountID, userIDPtr, req)
if err != nil {
applogger.L().Errorf("Create dashboard app: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create dashboard app")
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, err.Error())
return
}
response.Created(c, app)
c.JSON(http.StatusOK, serializeDashboardApp(app))
}
// Get retrieves a dashboard app by ID.
@@ -68,14 +78,20 @@ func (h *DashboardAppHandler) Get(c *gin.Context) {
return
}
app, err := h.svc.GetByID(c.Request.Context(), id)
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
app, err := h.svc.GetByAccountAndID(c.Request.Context(), accountID, id)
if err != nil {
applogger.L().Errorf("Get dashboard app: %v", err)
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "dashboard app not found")
return
}
response.OK(c, app)
c.JSON(http.StatusOK, serializeDashboardApp(app))
}
// Update modifies an existing dashboard app.
@@ -88,21 +104,26 @@ func (h *DashboardAppHandler) Update(c *gin.Context) {
return
}
var wrapper service.DashboardAppUpdateWrapper
if err := c.ShouldBindJSON(&wrapper); err != nil {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
req, err := bindDashboardAppUpdate(c)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
req := wrapper.DashboardApp
app, err := h.svc.Update(c.Request.Context(), id, &req)
app, err := h.svc.UpdateByAccountAndID(c.Request.Context(), accountID, id, req)
if err != nil {
applogger.L().Errorf("Update dashboard app: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update dashboard app")
handleDashboardAppMutationError(c, err)
return
}
response.OK(c, app)
c.JSON(http.StatusOK, serializeDashboardApp(app))
}
// Delete removes a dashboard app.
@@ -115,8 +136,18 @@ func (h *DashboardAppHandler) Delete(c *gin.Context) {
return
}
if err := h.svc.Delete(c.Request.Context(), id); err != nil {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
if err := h.svc.DeleteByAccountAndID(c.Request.Context(), accountID, id); err != nil {
applogger.L().Errorf("Delete dashboard app: %v", err)
if errors.Is(err, gorm.ErrRecordNotFound) {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "dashboard app not found")
return
}
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete dashboard app")
return
}
@@ -133,21 +164,26 @@ func (h *DashboardAppHandler) Patch(c *gin.Context) {
return
}
var wrapper service.DashboardAppUpdateWrapper
if err := c.ShouldBindJSON(&wrapper); err != nil {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
req, err := bindDashboardAppUpdate(c)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
req := wrapper.DashboardApp
app, err := h.svc.Update(c.Request.Context(), id, &req)
app, err := h.svc.UpdateByAccountAndID(c.Request.Context(), accountID, id, req)
if err != nil {
applogger.L().Errorf("Patch dashboard app: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update dashboard app")
handleDashboardAppMutationError(c, err)
return
}
response.OK(c, app)
c.JSON(http.StatusOK, serializeDashboardApp(app))
}
// List returns all dashboard apps for an account.
@@ -167,8 +203,67 @@ func (h *DashboardAppHandler) List(c *gin.Context) {
return
}
// Chatwoot returns a pure JSON array (no meta/pagination wrapper)
c.JSON(http.StatusOK, apps)
payload := make([]dashboardAppPayload, 0, len(apps))
for i := range apps {
payload = append(payload, serializeDashboardApp(&apps[i]))
}
c.JSON(http.StatusOK, payload)
}
func bindDashboardAppCreate(c *gin.Context) (*service.CreateDashboardAppRequest, error) {
var raw map[string]json.RawMessage
if err := c.ShouldBindJSON(&raw); err != nil {
return nil, err
}
if nested, ok := raw["dashboard_app"]; ok {
var req service.CreateDashboardAppRequest
if err := json.Unmarshal(nested, &req); err != nil {
return nil, err
}
return &req, nil
}
body, _ := json.Marshal(raw)
var req service.CreateDashboardAppRequest
if err := json.Unmarshal(body, &req); err != nil {
return nil, err
}
return &req, nil
}
func bindDashboardAppUpdate(c *gin.Context) (*service.UpdateDashboardAppRequest, error) {
var raw map[string]json.RawMessage
if err := c.ShouldBindJSON(&raw); err != nil {
return nil, err
}
if nested, ok := raw["dashboard_app"]; ok {
var req service.UpdateDashboardAppRequest
if err := json.Unmarshal(nested, &req); err != nil {
return nil, err
}
return &req, nil
}
body, _ := json.Marshal(raw)
var req service.UpdateDashboardAppRequest
if err := json.Unmarshal(body, &req); err != nil {
return nil, err
}
return &req, nil
}
func serializeDashboardApp(app *model.DashboardApp) dashboardAppPayload {
content := app.Content
if len(content) == 0 {
content = json.RawMessage(`[]`)
}
return dashboardAppPayload{ID: app.ID, Title: app.Title, Content: content, CreatedAt: app.CreatedAt}
}
func handleDashboardAppMutationError(c *gin.Context, err error) {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "dashboard app not found")
return
}
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, err.Error())
}
// ========== GoChat Extension Endpoints (not in Chatwoot) ==========
@@ -297,4 +392,4 @@ func (h *DashboardAppHandler) UpdateWidget(c *gin.Context) {
}
response.OK(c, widgets)
}
}
@@ -117,14 +117,22 @@ func (s *DashboardAppHandlerTestSuite) TestCreate_Success() {
c.Next()
}, s.handler.Create)
// Chatwoot requires nested {dashboard_app: {title: "...", content: [...]}}
body := `{"dashboard_app": {"title": "Test Dashboard App", "content": [{"type": "frame", "url": "https://example.com/widget"}]}}`
// The reused Chatwoot frontend sends a raw payload; Rails wraps it into
// dashboard_app server-side, so GoChat accepts both shapes.
body := `{"title": "Test Dashboard App", "content": [{"type": "frame", "url": "https://example.com/widget"}]}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps", s.account.ID), bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusCreated, w.Code)
assert.Equal(s.T(), http.StatusOK, w.Code)
var payload map[string]any
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
assert.NotContains(s.T(), payload, "success")
assert.NotContains(s.T(), payload, "data")
assert.Equal(s.T(), "Test Dashboard App", payload["title"])
assert.NotContains(s.T(), payload, "account_id")
assert.NotContains(s.T(), payload, "updated_at")
}
func (s *DashboardAppHandlerTestSuite) TestGet_Success() {
@@ -144,6 +152,42 @@ func (s *DashboardAppHandlerTestSuite) TestGet_Success() {
s.T().Logf("Get response: status=%d, body=%s", w.Code, w.Body.String())
assert.Equal(s.T(), http.StatusOK, w.Code)
var payload map[string]any
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
assert.NotContains(s.T(), payload, "success")
assert.NotContains(s.T(), payload, "data")
assert.Equal(s.T(), "Seed App", payload["title"])
assert.Contains(s.T(), payload, "created_at")
assert.NotContains(s.T(), payload, "account_id")
}
func (s *DashboardAppHandlerTestSuite) TestPatch_RawPayloadAndAccountScope() {
r := gin.New()
r.PATCH("/api/v1/accounts/:account_id/dashboard_apps/:dashboard_app_id", s.handler.Patch)
seedApp, err := s.handler.svc.Create(context.Background(), s.account.ID, &s.user.ID, &service.CreateDashboardAppRequest{
Title: "Before",
Content: json.RawMessage(`[{"type":"frame","url":"https://example.com/before"}]`),
})
s.Require().NoError(err)
body := `{"title":"After","content":[{"type":"frame","url":"https://example.com/after"}]}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps/%d", s.account.ID, seedApp.ID), bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var payload map[string]any
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
assert.Equal(s.T(), "After", payload["title"])
assert.NotContains(s.T(), payload, "success")
w = httptest.NewRecorder()
req, _ = http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps/%d", s.account.ID+100, seedApp.ID), bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
func (s *DashboardAppHandlerTestSuite) TestDelete_Success() {
@@ -162,4 +206,4 @@ func (s *DashboardAppHandlerTestSuite) TestDelete_Success() {
// Chatwoot: head :no_content → 204
assert.Equal(s.T(), http.StatusNoContent, w.Code)
}
}
+26 -21
View File
@@ -3,6 +3,7 @@ package model
import (
"encoding/json"
"fmt"
"strings"
"time"
"gorm.io/gorm"
@@ -16,25 +17,26 @@ import (
// account and optionally to a specific user (personal dashboards).
//
// Content format: JSON array of iframe configurations, e.g.:
// [{"type": "frame", "url": "https://example.com/widget"}]
//
// [{"type": "frame", "url": "https://example.com/widget"}]
//
// Validation rules (per Chatwoot):
// - content must be a JSON array (empty array allowed)
// - content must be a JSON array with at least one widget
// - each element must have type="frame" and url (http/https URI)
type DashboardApp struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AccountID uint `gorm:"not null;index" json:"account_id"`
UserID *uint `gorm:"index" json:"user_id,omitempty"` // optional: per-user dashboard
Title string `gorm:"size:255;not null" json:"title"`
Description string `gorm:"type:text" json:"description,omitempty"`
Icon string `gorm:"size:255" json:"icon,omitempty"` // icon URL or icon name
URL string `gorm:"size:512" json:"url,omitempty"` // primary iframe URL
Kind string `gorm:"size:100;default:'frame'" json:"kind"` // frame, link
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AccountID uint `gorm:"not null;index" json:"account_id"`
UserID *uint `gorm:"index" json:"user_id,omitempty"` // optional: per-user dashboard
Title string `gorm:"size:255;not null" json:"title"`
Description string `gorm:"type:text" json:"description,omitempty"`
Icon string `gorm:"size:255" json:"icon,omitempty"` // icon URL or icon name
URL string `gorm:"size:512" json:"url,omitempty"` // primary iframe URL
Kind string `gorm:"size:100;default:'frame'" json:"kind"` // frame, link
Content json.RawMessage `gorm:"type:json;serializer:json;default:'[]'" json:"content"` // iframe config array
Active *bool `gorm:"default:true;not null" json:"active"`
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"`
Active *bool `gorm:"default:true;not null" json:"active"`
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"`
// Relations
Account *Account `gorm:"foreignKey:AccountID" json:"account,omitempty"`
@@ -52,9 +54,9 @@ func BoolPtr(b bool) *bool { return &b }
// This is an in-memory structure used for widget CRUD — not a separate DB table.
// Widgets are stored as elements in the DashboardApp.Content jsonb array.
type DashboardWidget struct {
Type string `json:"type"` // must be "frame"
URL string `json:"url"` // must be http/https URI
ID string `json:"id,omitempty"` // optional client-assigned widget ID
Type string `json:"type"` // must be "frame"
URL string `json:"url"` // must be http/https URI
ID string `json:"id,omitempty"` // optional client-assigned widget ID
Name string `json:"name,omitempty"` // optional widget display name
}
@@ -65,19 +67,22 @@ type DashboardWidget struct {
// - each element must have type="frame" and url (http/https URI)
func ValidateContent(content json.RawMessage) error {
if len(content) == 0 || string(content) == "" {
return nil // empty content is OK (will be stored as [])
return fmt.Errorf("content must be a JSON array")
}
var widgets []DashboardWidget
if err := json.Unmarshal(content, &widgets); err != nil {
return fmt.Errorf("content must be a JSON array: %w", err)
}
if len(widgets) == 0 {
return fmt.Errorf("content must contain at least one widget")
}
for i, w := range widgets {
if w.Type != "" && w.Type != "frame" {
if w.Type != "frame" {
return fmt.Errorf("widget[%d].type must be 'frame', got '%s'", i, w.Type)
}
if w.URL != "" && !IsValidHTTPURL(w.URL) {
if strings.TrimSpace(w.URL) == "" || !IsValidHTTPURL(w.URL) {
return fmt.Errorf("widget[%d].url must be http/https URI, got '%s'", i, w.URL)
}
}
@@ -87,4 +92,4 @@ func ValidateContent(content json.RawMessage) error {
// IsValidHTTPURL checks if a URL uses http or https scheme.
func IsValidHTTPURL(u string) bool {
return (len(u) >= 7 && u[:7] == "http://") || (len(u) >= 8 && u[:8] == "https://")
}
}
+13 -1
View File
@@ -28,6 +28,14 @@ func (r *DashboardAppRepo) GetByID(ctx context.Context, id uint) (*model.Dashboa
return &app, nil
}
func (r *DashboardAppRepo) GetByAccountAndID(ctx context.Context, accountID, id uint) (*model.DashboardApp, error) {
var app model.DashboardApp
if err := r.db.WithContext(ctx).Where("account_id = ? AND id = ?", accountID, id).First(&app).Error; err != nil {
return nil, err
}
return &app, nil
}
func (r *DashboardAppRepo) Update(ctx context.Context, app *model.DashboardApp) error {
return r.db.WithContext(ctx).Save(app).Error
}
@@ -36,6 +44,10 @@ func (r *DashboardAppRepo) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.DashboardApp{}, id).Error
}
func (r *DashboardAppRepo) DeleteByAccountAndID(ctx context.Context, accountID, id uint) error {
return r.db.WithContext(ctx).Where("account_id = ? AND id = ?", accountID, id).Delete(&model.DashboardApp{}).Error
}
// FindAllByAccountID returns all dashboard apps for an account (no pagination).
// Chatwoot: Current.account.dashboard_apps — returns all without limit/offset
func (r *DashboardAppRepo) FindAllByAccountID(ctx context.Context, accountID uint) ([]model.DashboardApp, error) {
@@ -105,4 +117,4 @@ func (r *DashboardAppRepo) FindAllActiveByAccountID(ctx context.Context, account
return nil, err
}
return apps, nil
}
}
+70 -2
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
@@ -89,7 +90,7 @@ func (s *DashboardAppService) Create(ctx context.Context, accountID uint, userID
URL: req.URL,
Kind: kind,
Content: content,
Active: active,
Active: active,
}
if err := s.repo.Create(ctx, app); err != nil {
applogger.L().Errorf("Create dashboard app: %v", err)
@@ -107,6 +108,15 @@ func (s *DashboardAppService) GetByID(ctx context.Context, id uint) (*model.Dash
return app, nil
}
func (s *DashboardAppService) GetByAccountAndID(ctx context.Context, accountID, id uint) (*model.DashboardApp, error) {
app, err := s.repo.GetByAccountAndID(ctx, accountID, id)
if err != nil {
applogger.L().Errorf("Get dashboard app: %v", err)
return nil, err
}
return app, nil
}
func (s *DashboardAppService) Update(ctx context.Context, id uint, req *UpdateDashboardAppRequest) (*model.DashboardApp, error) {
app, err := s.repo.GetByID(ctx, id)
if err != nil {
@@ -150,6 +160,48 @@ func (s *DashboardAppService) Update(ctx context.Context, id uint, req *UpdateDa
return app, nil
}
func (s *DashboardAppService) UpdateByAccountAndID(ctx context.Context, accountID, id uint, req *UpdateDashboardAppRequest) (*model.DashboardApp, error) {
app, err := s.repo.GetByAccountAndID(ctx, accountID, id)
if err != nil {
applogger.L().Errorf("Get dashboard app for update: %v", err)
return nil, err
}
if len(req.Content) > 0 && string(req.Content) != "" {
if err := model.ValidateContent(req.Content); err != nil {
return nil, err
}
}
if strings.TrimSpace(req.Title) != "" {
app.Title = req.Title
}
if req.Description != "" {
app.Description = req.Description
}
if req.Icon != "" {
app.Icon = req.Icon
}
if req.URL != "" {
app.URL = req.URL
}
if req.Kind != "" {
app.Kind = req.Kind
}
if len(req.Content) > 0 && string(req.Content) != "" {
app.Content = req.Content
}
if req.Active != nil {
app.Active = req.Active
}
if err := s.repo.Update(ctx, app); err != nil {
applogger.L().Errorf("Update dashboard app: %v", err)
return nil, err
}
return app, nil
}
func (s *DashboardAppService) Delete(ctx context.Context, id uint) error {
app, err := s.repo.GetByID(ctx, id)
if err != nil {
@@ -166,6 +218,22 @@ func (s *DashboardAppService) Delete(ctx context.Context, id uint) error {
return nil
}
func (s *DashboardAppService) DeleteByAccountAndID(ctx context.Context, accountID, id uint) error {
app, err := s.repo.GetByAccountAndID(ctx, accountID, id)
if err != nil {
applogger.L().Errorf("Delete dashboard app: find failed: %v", err)
return err
}
if app == nil {
return fmt.Errorf("dashboard app not found")
}
if err := s.repo.DeleteByAccountAndID(ctx, accountID, id); err != nil {
applogger.L().Errorf("Delete dashboard app: %v", err)
return err
}
return nil
}
// ListByAccount returns all dashboard apps for an account.
// Chatwoot: Current.account.dashboard_apps — no pagination
func (s *DashboardAppService) ListByAccount(ctx context.Context, accountID uint) ([]model.DashboardApp, error) {
@@ -381,4 +449,4 @@ func (s *DashboardAppService) GetWidgets(ctx context.Context, dashboardAppID uin
}
}
return widgets, nil
}
}
@@ -64,9 +64,9 @@ func TestDashboardAppService_Create_空Content(t *testing.T) {
}
app, err := svc.Create(context.Background(), account.ID, nil, req)
require.NoError(t, err)
assert.NotZero(t, app.ID)
assert.Equal(t, json.RawMessage("[]"), app.Content)
assert.Error(t, err)
assert.Nil(t, app)
assert.Contains(t, err.Error(), "at least one widget")
}
func TestDashboardAppService_Create_无效Content非数组(t *testing.T) {
@@ -482,4 +482,4 @@ func TestDashboardAppService_UpdateWidget_不存在App(t *testing.T) {
app, err := svc.UpdateWidget(context.Background(), 99999, 0, req)
assert.Error(t, err)
assert.Nil(t, app)
}
}