From 75f0e809a5849667f7a5efc50c55fe3ae7dbcd44 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 6 Jun 2026 13:03:16 +0800 Subject: [PATCH] feat(dashboard-apps): align chatwoot payloads --- cmd/route_parity/main.go | 7 + docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 24 +-- docs/parity/route_parity.md | 8 +- .../handler/api/v1/dashboard_app_handler.go | 151 ++++++++++++++---- .../api/v1/dashboard_app_handler_test.go | 52 +++++- internal/model/dashboard_app.go | 47 +++--- internal/repository/dashboard_app_repo.go | 14 +- internal/service/dashboard_app_service.go | 72 ++++++++- .../service/dashboard_app_service_test.go | 8 +- 9 files changed, 312 insertions(+), 71 deletions(-) diff --git a/cmd/route_parity/main.go b/cmd/route_parity/main.go index 76cf7fed..9cf1af59 100644 --- a/cmd/route_parity/main.go +++ b/cmd/route_parity/main.go @@ -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"}, diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index bfaf582a..2dbe82d1 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -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. diff --git a/docs/parity/route_parity.md b/docs/parity/route_parity.md index ed47a213..37553188 100644 --- a/docs/parity/route_parity.md +++ b/docs/parity/route_parity.md @@ -7,7 +7,7 @@ Generated from: This report covers tracked frontend-critical Chatwoot routes from `reference/chatwoot/config/routes.rb`, including API v1 account routes, Captain/Copilot, assignment policies, widget/public APIs, and API v2 reports. Ruby is not installed in the workspace, so Chatwoot routes are sourced from static route declarations instead of `bin/rails routes`. -Summary: 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 | diff --git a/internal/handler/api/v1/dashboard_app_handler.go b/internal/handler/api/v1/dashboard_app_handler.go index 5530719f..04e14a74 100644 --- a/internal/handler/api/v1/dashboard_app_handler.go +++ b/internal/handler/api/v1/dashboard_app_handler.go @@ -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) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/dashboard_app_handler_test.go b/internal/handler/api/v1/dashboard_app_handler_test.go index 29867045..ee6b96b8 100644 --- a/internal/handler/api/v1/dashboard_app_handler_test.go +++ b/internal/handler/api/v1/dashboard_app_handler_test.go @@ -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) -} \ No newline at end of file +} diff --git a/internal/model/dashboard_app.go b/internal/model/dashboard_app.go index 9ef3f1b3..9a4cc8ae 100644 --- a/internal/model/dashboard_app.go +++ b/internal/model/dashboard_app.go @@ -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://") -} \ No newline at end of file +} diff --git a/internal/repository/dashboard_app_repo.go b/internal/repository/dashboard_app_repo.go index 12a02fed..37dc0ecd 100644 --- a/internal/repository/dashboard_app_repo.go +++ b/internal/repository/dashboard_app_repo.go @@ -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 -} \ No newline at end of file +} diff --git a/internal/service/dashboard_app_service.go b/internal/service/dashboard_app_service.go index dd0cb645..c4672ee8 100644 --- a/internal/service/dashboard_app_service.go +++ b/internal/service/dashboard_app_service.go @@ -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 -} \ No newline at end of file +} diff --git a/internal/service/dashboard_app_service_test.go b/internal/service/dashboard_app_service_test.go index 2b4bf6d8..80cad894 100644 --- a/internal/service/dashboard_app_service_test.go +++ b/internal/service/dashboard_app_service_test.go @@ -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) -} \ No newline at end of file +}