From 57f864e68cc79acedd281a7fa1ef8fb09d00f184 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 12:56:25 +0800 Subject: [PATCH] feat(captain): align preferences payloads --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 39 ++- docs/parity/gochat_routes.txt | 4 +- internal/app/bootstrap.go | 2 +- .../api/v1/captain_preference_handler.go | 42 +-- .../api/v1/captain_preference_handler_test.go | 206 +++++++------ internal/model/account.go | 26 +- internal/router/router.go | 4 +- .../service/captain_preference_service.go | 273 ++++++++++++++++-- ...5_add_account_captain_preferences.down.sql | 3 + ...025_add_account_captain_preferences.up.sql | 5 + 10 files changed, 436 insertions(+), 168 deletions(-) create mode 100644 migrations/000025_add_account_captain_preferences.down.sql create mode 100644 migrations/000025_add_account_captain_preferences.up.sql diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 548c73ad..07fb2330 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -16,9 +16,9 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc ## Current Baseline -- Latest implementation checkpoint: `feat(copilot): align thread message payloads`. -- Latest documentation checkpoint: this checkpoint, recorded with the B11.2 Copilot thread/message persistence and payload implementation. -- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence. Next active slice is B11.3 document sync, embedding, Meilisearch, and external LLM gates. +- Latest implementation checkpoint: `feat(captain): align preferences payloads`. +- Latest documentation checkpoint: this checkpoint, recorded with the B11.3a Captain preferences account-level payload implementation. +- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage. Next active slice is B11.3b document sync, embedding, Meilisearch, and external LLM gates. - `go test ./...` passes. - Route dump succeeds with `TOTAL: 830` after adding the Chatwoot-compatible applied-SLA index route. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -55,7 +55,7 @@ Next ordered checkpoints: | Phase 1 | Meilisearch search engine | Review | B6 payload parity, optional live gate, and DB-fallback hardening are implemented; an actual live Meilisearch run is optional and environment-dependent | | Phase 2 | Route and controller parity audit | Doing | Ruby/Bundler unavailable, so Chatwoot route extraction currently uses static `routes.rb` fallback | | Phase 3 | Data and serializer parity | Doing | JSON fixture coverage is partial and still endpoint-family based | -| Phase 4 | Enterprise feature completion | Doing | B7, B8, B9, and B10 are in Review; B11 Captain resources and Copilot thread/message payloads are landed, while Copilot tasks/preferences/tool-call depth and document/LLM/Meilisearch gates remain active | +| Phase 4 | Enterprise feature completion | Doing | B7, B8, B9, and B10 are in Review; B11 Captain resources, preferences, and Copilot thread/message payloads are landed, while Copilot tasks/tool-call depth and document/LLM/Meilisearch gates remain active | | Phase 5 | Background jobs and integrations | Planned | durable worker choice and job parity are open | | Phase 6 | Core placeholder burn-down | Doing | account/contact/conversation/message/inbox placeholder groups remain broad | | Phase 7 | Verification harness | Planned | search live gate and reused-frontend smoke harness are not complete | @@ -150,12 +150,13 @@ This ledger records the committed parity checkpoints that future slices should b | `feat(captain): align scenario and tool payloads` | Advanced B11.1b for Captain scenarios and custom tools: scenario create/list/show/update/delete now accept nested `{ scenario }`, enforce account/assistant scope, return Chatwoot raw scenario serializers and `{ payload, meta }` lists, hide disabled scenarios from index, and return `204` on delete; custom tools now accept nested `{ custom_tool }`, auto-generate slugs from title, enforce account scope, and return raw tool serializers or `{ payload, meta }` lists. | `go test ./internal/handler/api/v1 -run 'CaptainScenario\|CaptainCustomTool\|CaptainResource' -count=1`; `go test ./internal/handler/api/v1 -run 'Captain\|Copilot' -count=1`; `go test ./internal/service -run 'Captain\|Copilot' -count=1`; focused fixtures cover nested frontend bodies, account scoping, disabled scenario filtering, raw payloads, custom-tool slug generation, and no local envelopes. | Continue B11.1c with Captain documents, assistant responses, and bulk actions; custom-tool `test` still needs the reference unsaved-tool execution contract and safe network boundary before B11 leaves Review. | | `feat(captain): align document response actions` | Completes B11.1c frontend payload depth for Captain documents, assistant responses, bulk actions, and custom-tool test: document list/create/show/sync/delete now use account-scoped raw serializers and `{ payload, meta }`; assistant responses use nested `{ assistant_response }`, account-scoped raw serializers, filters, edited flag, and `204` delete; bulk actions accept Chatwoot `{ type, ids, fields.status }` for `AssistantResponse` and `AssistantDocument`; custom-tool test accepts unsaved nested `{ custom_tool }` configs and returns raw `{ status, body }` with a fakeable HTTP boundary. | Focused B11.1c handler tests cover document list/create/show/sync/delete, response list/create/show/update/delete, bulk approve/delete/sync/delete shapes, custom-tool test success/error contract, account scoping, sync status, and no local envelopes. Verification passed: focused B11.1c handler tests, Captain/Copilot service tests, Captain repository tests, handler/service package tests, escalated full `go test ./...`, and `git diff --check`. | Continue B11.2 with Copilot persistence, disabled-state payloads, and document/LLM/Meilisearch gates. B11 remains Doing until Copilot and external-provider gates are tracked and tested. | | `feat(copilot): align thread message payloads` | Completes the B11.2 Copilot thread/message frontend contract: thread create accepts Chatwoot `{ message, assistant_id, conversation_id }`, creates the initial user message plus deterministic assistant fallback when no LLM provider is configured, validates assistant account scope, and returns raw thread payloads. Thread list returns `{ payload }` scoped to current account/user with Chatwoot page size/order. Nested `copilot_messages` list/create returns raw message payloads with embedded thread/user/assistant push data and `204` delete remains available for local compatibility. | `go test ./internal/handler/api/v1 -run 'Copilot' -count=1`; `go test ./internal/handler/api/v1 -run 'Captain\|Copilot' -count=1`; `go test ./internal/service -run 'Captain\|Copilot' -count=1`; `go test ./internal/repository -run 'Copilot' -count=1`; handler/service/repository package tests; escalated full `go test ./...`; `git diff --check`. Focused handler fixtures cover no local envelopes, account/user isolation, assistant scope, nested message order, and no-LLM assistant fallback persistence. | Continue B11.3 with Captain document sync/indexing, Meilisearch/embedding gates, Copilot tasks/preferences/tool-call/playground depth, streaming/realtime compatibility, and reused frontend smoke coverage. | +| `feat(captain): align preferences payloads` | Starts B11.3a by replacing the legacy local `captain_preferences` CRUD response with Chatwoot's account-level Captain preferences contract: `GET/PUT /captain/preferences` now return raw `{ providers, models, features }`, `captain_models` and `captain_features` persist on accounts, updates merge with existing account settings, invalid model selections return `422`, and update is administrator-gated. Route artifacts were regenerated after removing frontend-unused POST/DELETE preference routes and adding exact no-trailing-slash GET/PUT paths. | `go test ./internal/handler/api/v1 -run 'CaptainPreference' -count=1`; `go test ./internal/service -run 'CaptainPreference' -count=1`; `go test ./internal/handler/api/v1 -run 'Captain\|Copilot' -count=1`; `go test ./internal/service -run 'Captain\|Copilot' -count=1`; `go test ./internal/handler/api/v1 -count=1`; `go test ./internal/service -count=1`; `go test ./internal/model -count=1`; `go test ./internal/router -count=1`; `go test ./cmd/migrate -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; escalated full `go test ./...`; `git diff --check`. | Continue B11.3b with document sync/indexing, Meilisearch/embedding gates, remaining Copilot task/tool-call/playground depth, streaming/realtime compatibility, and reused frontend smoke coverage. | ## Next Slice Contract -Completed implementation slice: B11.2 now aligns Copilot thread and nested message persistence/payloads with the reused Chatwoot frontend, building on B11.1a-B11.1c Captain resource contracts. +Completed implementation slice: B11.3a now aligns Captain preferences show/update payloads with the reused Chatwoot frontend, building on B11.1a-B11.2 Captain/Copilot resource contracts. -Next implementation slice: continue B11.3 document sync/indexing, Meilisearch/embedding gates, Copilot tasks/preferences/tool-call/playground depth, and streaming/realtime compatibility. B9.3 delayed/durable worker scheduling and B9.4 macro attachment depth remain named Phase 5 follow-ups. +Next implementation slice: continue B11.3b document sync/indexing, Meilisearch/embedding gates, Copilot tasks/tool-call/playground depth, and streaming/realtime compatibility. B9.3 delayed/durable worker scheduling and B9.4 macro attachment depth remain named Phase 5 follow-ups. | Step | Required result | Reference source | Verification | | --- | --- | --- | --- | @@ -179,7 +180,8 @@ Next implementation slice: continue B11.3 document sync/indexing, Meilisearch/em | N18 | Keep B11.1b Captain scenario/custom-tool resources as current Captain baseline. | Captain scenarios/custom tools controllers, Jbuilder views, and dashboard clients. | Done by `feat(captain): align scenario and tool payloads`; scenarios and custom tools use nested frontend bodies, account scoping, raw payloads, and Chatwoot list meta. | | N19 | Keep B11.1c Captain document/response/bulk/test resources as current Captain baseline. | Captain documents, assistant responses, bulk actions, custom-tool test controllers, Jbuilder views, and dashboard clients. | Done by `feat(captain): align document response actions`; documents, assistant responses, bulk resource actions, and unsaved custom-tool test use account-scoped Chatwoot payloads with a fakeable HTTP boundary. | | N20 | Keep B11.2 Copilot thread/message resources as current Copilot baseline. | `copilot_threads_controller.rb`, `copilot_messages_controller.rb`, Copilot Jbuilder partials, and dashboard `copilotThreads.js`/`copilotMessages.js` clients. | Done by `feat(copilot): align thread message payloads`; threads/messages persist account/user-scoped Chatwoot payloads and no-LLM fallback assistant messages. | -| N21 | Update this tracker after every implementation checkpoint. | This document. | `git diff --check`; `go test ./...` for Go changes. | +| N21 | Keep B11.3a Captain preferences as current account-level Captain config baseline. | `preferences_controller.rb`, `CaptainFeaturable`, `config/llm.yml`, dashboard `preferences.js` API/store. | Done by `feat(captain): align preferences payloads`; raw model/provider/feature payloads, merge update behavior, account storage, admin gate, and exact preference routes are covered. | +| N22 | Update this tracker after every implementation checkpoint. | This document. | `git diff --check`; `go test ./...` for Go changes. | Current B2 profile checkpoint: @@ -410,7 +412,7 @@ Upcoming enterprise task boards: | B10 | B10.4 | Review remaining InboxLimit/account-limit create-path enforcement outside capacity policies. | Chatwoot enterprise account/inbox limit policies. | Inbox/channel create tests for over-limit behavior and frontend-readable errors. | Done by `feat(inboxes): enforce chatwoot inbox limits` | | B11 | B11.1 | Align Captain assistant CRUD, inbox bindings, responses, documents, scenarios, and custom tools payloads. | Captain controllers/services/frontend clients under `reference/chatwoot`. | Handler/service fixtures for every Captain dashboard client path. | Review; assistant CRUD/tools/inbox binding, scenarios, custom tools, documents, assistant responses, bulk actions, and custom-tool test payloads are landed | | B11 | B11.2 | Align Copilot threads, messages, tasks, preferences, playground/tool-call behavior, and disabled-state feature gates. | Copilot controllers/services/frontend clients under `reference/chatwoot`. | Copilot handler/service tests for persistence, disabled LLM state, and frontend payloads. | Review; thread/message payloads, account/user scoping, assistant scope, and no-LLM fallback are landed; tasks/preferences/tool-call/playground depth remains in B11.3 follow-up | -| B11 | B11.3 | Add document sync/embedding/LLM job boundaries where external dependencies are required and finish remaining Copilot task/preference/tool-call/streaming depth. | Captain/Copilot jobs, document services, Copilot controllers/services/frontend clients. | Worker tests or explicit feature-gated fallback tests plus Copilot task/preference/tool-call fixtures. | Todo | +| B11 | B11.3 | Add document sync/embedding/LLM job boundaries where external dependencies are required and finish remaining Copilot task/preference/tool-call/streaming depth. | Captain/Copilot jobs, document services, Copilot controllers/services/frontend clients. | Worker tests or explicit feature-gated fallback tests plus Copilot task/preference/tool-call fixtures. | Doing; B11.3a Captain preferences show/update payloads are landed, while document sync/indexing and remaining Copilot task/tool-call depth remain active | | B12 | B12.1 | Add a repeatable command to run the reused Chatwoot frontend against GoChat. | `reference/chatwoot` frontend boot/auth/API clients. | Smoke command documented and runnable locally. | Todo | | B12 | B12.2 | Cover login, inbox list/settings, conversation list/detail/message send, contact/company views, widget init/message, public CSAT, SLA/CSAT reports, and enterprise admin screens. | Dashboard route usage and frontend stores/API modules. | Smoke report checked into `docs/parity/` with pass/fail gaps. | Todo | @@ -771,7 +773,8 @@ B11 Captain/Copilot breakdown: | B11.1b | Align Captain scenario and custom-tool CRUD/list payloads. | `scenarios_controller.rb`, `custom_tools_controller.rb`, scenario/custom-tool Jbuilder views, dashboard Captain clients. | Handler fixtures for nested bodies, list/show/create/update/delete, account scoping, disabled scenario filtering, and no local response envelopes. | Done by `feat(captain): align scenario and tool payloads` | | B11.1c | Align Captain documents, assistant responses, bulk actions, and custom-tool test execution. | `documents_controller.rb`, `assistant_responses_controller.rb`, `bulk_actions_controller.rb`, `custom_tools_controller#test`, dashboard Captain clients. | Handler/service fixtures for document list/create/show/sync/delete, response list/show/create/update/delete, bulk-action payloads, test execution errors, account scoping, sync status, and no local response envelopes. | Done by `feat(captain): align document response actions` | | B11.2 | Align Copilot thread/message APIs, account/user scoping, assistant scope, frontend payloads, and no-LLM fallback persistence. | `copilot_threads_controller.rb`, `copilot_messages_controller.rb`, Copilot Jbuilder partials, dashboard `copilotThreads.js` and `copilotMessages.js`. | Handler tests cover thread/message create/list/get/delete, nested message ordering, assistant/account isolation, user isolation, and deterministic disabled LLM fallback. | Done by `feat(copilot): align thread message payloads` | -| B11.3 | Implement document sync/indexing gates for Meilisearch or the chosen embedding/search backend, and finish Copilot task/preference/playground/tool-call/streaming depth without blocking the frontend when LLM config is absent. | Captain document/embedding services plus remaining Copilot controllers/services/frontend clients. | Tests cover disabled state, failed sync observability, successful fake backend indexing, Copilot task/preference/tool-call persistence, and non-streaming fallback. | Todo | +| B11.3a | Align Captain preferences show/update payloads, account-level model/feature storage, and admin update gate. | `preferences_controller.rb`, `CaptainFeaturable`, `config/llm.yml`, dashboard `preferences.js` API/store. | Handler tests cover raw `{ providers, models, features }`, default selected models, merge update behavior, account persistence, invalid model rejection, admin gate, and exact preference routes. | Done by `feat(captain): align preferences payloads` | +| B11.3b | Implement document sync/indexing gates for Meilisearch or the chosen embedding/search backend, and finish Copilot task/playground/tool-call/streaming depth without blocking the frontend when LLM config is absent. | Captain document/embedding services plus remaining Copilot controllers/services/frontend clients. | Tests cover disabled state, failed sync observability, successful fake backend indexing, Copilot task/tool-call persistence, and non-streaming fallback. | Todo | B11.1a current checkpoint: @@ -809,13 +812,22 @@ B11.2 current checkpoint: - `GetThread`/`DeleteThread` remain available for local compatibility but now use the same account/user scoping; delete removes thread messages and returns `204`. - Focused tests cover raw payloads without local `{ success, data }`, assistant/account isolation, user isolation, nested message persistence/order, and the safe no-LLM assistant fallback message. +B11.3a current checkpoint: + +- Reference files inspected for this slice: `reference/chatwoot/app/controllers/api/v1/accounts/captain/preferences_controller.rb`, `app/models/concerns/captain_featurable.rb`, `config/llm.yml`, dashboard `api/captain/preferences.js`, and store `captain/preferences.js`. +- Captain preferences now follow Chatwoot `resource :preferences, only: [:show, :update]`: exact no-trailing-slash GET/PUT routes are registered and the frontend-unused local POST/DELETE preference routes are no longer mounted. +- Preference responses now return raw `{ providers, models, features }` instead of local `{ success, data }` or tone/language rows. Feature payloads include configured models, default model, account-selected model, and enabled state. +- Account-level `captain_models` and `captain_features` JSON fields now persist the same settings Chatwoot stores through `Account#settings`; updates merge partial incoming maps with existing account settings. +- Invalid model selections return `422 { error }`, non-admin updates return `401`, and agents can still read the preferences payload. +- Route dump was regenerated and remains `TOTAL: 830`; route parity remains `270 exact, 7 parameter-compatible, 0 missing`. + B11 landing rules: | Area | Landing requirement | Done signal | | --- | --- | --- | | Captain assistant resources | Audit the current `reference/chatwoot` Captain route/controller/frontend client set before code changes, then implement account-scoped CRUD and nested assistant resources with raw frontend-compatible payloads. | Handler tests cover list/show/create/update/delete, inbox binding, response/scenario/document/custom-tool paths, and disabled-state responses. | | Captain document sync | External embedding/LLM work must sit behind fakeable interfaces and config gates; missing provider config must not break the reused frontend. | Tests cover disabled config, fake successful sync, failure metadata, and no unhandled external call in default test mode. | -| Copilot persistence | Threads/messages are now persisted and frontend-shaped; tasks, preferences, playground state, and tool-call records must still persist enough data for frontend reloads. | B11.2 tests cover thread/message create/list/get/delete and account/user scoping; B11.3 must add tool-call serialization and task/preference lifecycle tests. | +| Copilot persistence | Threads/messages and Captain preferences are now persisted and frontend-shaped; tasks, playground state, and tool-call records must still persist enough data for frontend reloads. | B11.2 tests cover thread/message create/list/get/delete and account/user scoping; B11.3a tests cover preferences; B11.3b must add tool-call serialization and task lifecycle tests. | | Streaming fallback | If Chatwoot streams a response but GoChat cannot yet stream safely, return a documented frontend-compatible non-streaming or disabled state rather than a placeholder success. | Tests prove the frontend API path receives a deterministic payload/status. | | Deferred external depth | Model/provider-specific LLM behavior may be feature-gated, but every gate must be visible in this tracker and covered by tests. | B11 stays `Review`, not `Done`, while any external-provider depth remains deferred. | @@ -1207,7 +1219,7 @@ Included checklist: - [x] Custom roles and permission checks. - [x] Agent capacity and assignment limits. - [ ] Assignment policies and auto-assignment compatibility. -- [ ] Captain/Copilot assistant, custom tools, scenarios, documents, responses, inbox bindings, and Copilot threads/messages. Captain assistant CRUD/tools/inbox binding, scenario/custom-tool CRUD, documents, assistant responses, bulk actions, custom-tool test payloads, and Copilot thread/message payloads are landed; external document/LLM gates and remaining Copilot task/tool-call depth remain active. +- [ ] Captain/Copilot assistant, custom tools, scenarios, documents, responses, inbox bindings, preferences, and Copilot threads/messages. Captain assistant CRUD/tools/inbox binding, scenario/custom-tool CRUD, documents, assistant responses, bulk actions, custom-tool test payloads, preferences, and Copilot thread/message payloads are landed; external document/LLM gates and remaining Copilot task/tool-call depth remain active. - [x] CSAT survey response flow, metrics, filters, and review notes. - [x] Inbox limits and account/inbox usage enforcement. - [x] Automation rules, macros, execution logs, and action side effects. @@ -1226,7 +1238,7 @@ Enterprise tracking table: | P4.3 | Custom roles/permissions | `internal/model/custom_role.go`, `internal/service/custom_role_service.go`, `internal/middleware/role_check.go`, `internal/handler/api/v1/custom_role_handler.go` | Permission arrays, raw admin payloads, administrator gates, account-user resolution, profile permissions, and delete nullification are implemented; keep in Review until frontend smoke covers enterprise settings. | Review | | P4.4 | Agent capacity | `internal/model/agent_capacity_policy.go`, `internal/service/agent_capacity_policy_service.go`, `internal/handler/api/v1/agent_capacity_handler.go`, `internal/autoassignment/*` | First align AgentCapacityPolicy API, serializers, nested users, and `InboxCapacityLimit` data contracts; then enforce capacity in manual/auto assignment. | Review | | P4.5 | Inbox limits | `internal/model/agent_capacity_policy.go` `InboxCapacityLimit`, plus legacy `internal/model/inbox_limit.go`, `internal/service/inbox_limit_service.go`, `internal/repository/inbox_limit_repo.go`, `internal/handler/api/v1/inbox_limit_handler.go` | Chatwoot enterprise nested `agent_capacity_policies/:policy_id/inbox_limits`, assignment-path enforcement, and account-level `usage_limits[:inboxes]` create-path enforcement are covered. | Review | -| P4.6 | Captain/Copilot | `internal/model/captain_models.go`, `internal/model/copilot_models.go`, `internal/service/captain_*`, `internal/service/copilot_*`, `internal/handler/api/v1/captain_*`, `internal/handler/api/v1/copilot_handler.go` | Captain resource payloads through B11.1c and Copilot thread/message payloads through B11.2 are aligned; complete Copilot tasks/preferences/tool-call depth, suggestions, document/LLM gates, and streaming compatibility. | Doing | +| P4.6 | Captain/Copilot | `internal/model/captain_models.go`, `internal/model/copilot_models.go`, `internal/service/captain_*`, `internal/service/copilot_*`, `internal/handler/api/v1/captain_*`, `internal/handler/api/v1/copilot_handler.go` | Captain resource payloads through B11.1c, Copilot thread/message payloads through B11.2, and Captain preferences through B11.3a are aligned; complete Copilot tasks/tool-call depth, suggestions, document/LLM gates, and streaming compatibility. | Doing | | P4.7 | CSAT | `internal/csat/*`, `internal/automation/csat_survey_*`, `internal/handler/api/v1/csat_*`, `internal/service/csat_metrics_service.go` | Account-side list/metrics/review-note payloads, public update depth, resolve-triggered generic survey send, and download CSV are aligned; channel-specific template send hooks remain Phase 5 integration work. | Review | | P4.8 | Automation and macros | `internal/automation/*`, `internal/handler/api/v1/automation_rule_handler.go`, `internal/handler/api/v1/macro_handler.go` | Automation-rule CRUD/listener/log/external-action delivery and macro CRUD/display-ID execution parity are implemented; durable delayed actions and deeper macro attachment/file parity remain B9.3/B9.4 follow-ups. | Review | | P4.9 | Assignment policies | `internal/autoassignment/*`, `internal/automation/agent_bot_rule_listener.go` | Match Chatwoot assignment policy behavior and availability/capacity rules. | Doing | @@ -1243,7 +1255,7 @@ Enterprise work package breakdown: | Audit | Audit model parity, mutating action coverage, request metadata, filters/pagination, admin endpoint payloads. | List/admin payload parity is covered by `feat(audit): align chatwoot audit log payloads`; named representative writer coverage is covered by `feat(audit): record enterprise mutations` and `feat(audit): cover operational mutations`. | Review | | Custom roles | Permission-key parity, account-user role resolution, policy middleware, create/update/delete behavior. | Permission arrays, raw admin payloads, admin gates, RBAC/account-scope/profile resolution, and delete nullification are covered by `feat(custom-roles): align chatwoot permissions`. | Review | | Inbox limits | Account/inbox limit models, create/update enforcement, UI-readable limit responses, admin overrides. | Chatwoot nested capacity-limit API, assignment-path enforcement, and account-level create-limit `402` behavior are covered by B5/B7/B10.4. | Review | -| Captain/Copilot | Assistants, inbox bindings, scenarios, responses, documents, tools, copilot threads/messages, tasks, streaming/tool-call behavior. | Assistant CRUD/tools/inbox binding fixtures are covered by `feat(captain): align assistant resources`; scenario/custom-tool fixtures are covered by `feat(captain): align scenario and tool payloads`; document/assistant-response/bulk/custom-tool-test fixtures are covered by `feat(captain): align document response actions`; Copilot thread/message fixtures are covered by `feat(copilot): align thread message payloads`; feature gates for external LLM dependencies, remaining Copilot tasks/tool-calls, and frontend smoke screens remain. | Doing | +| Captain/Copilot | Assistants, inbox bindings, scenarios, responses, documents, tools, preferences, copilot threads/messages, tasks, streaming/tool-call behavior. | Assistant CRUD/tools/inbox binding fixtures are covered by `feat(captain): align assistant resources`; scenario/custom-tool fixtures are covered by `feat(captain): align scenario and tool payloads`; document/assistant-response/bulk/custom-tool-test fixtures are covered by `feat(captain): align document response actions`; Copilot thread/message fixtures are covered by `feat(copilot): align thread message payloads`; preferences fixtures are covered by `feat(captain): align preferences payloads`; feature gates for external LLM dependencies, remaining Copilot tasks/tool-calls, and frontend smoke screens remain. | Doing | Enterprise acceptance gates: @@ -1482,3 +1494,4 @@ Verification milestone gates: - 2026-06-05: B11.1b Captain scenario/custom-tool checkpoint prepared as `feat(captain): align scenario and tool payloads`; scenarios now accept nested frontend bodies, enforce account/assistant scope, list only enabled scenarios, and return raw scenario/list/204 payloads. Custom tools now accept nested frontend bodies, auto-generate slugs, enforce account scope, and return raw tool/list/204 payloads. Focused CaptainScenario/CaptainCustomTool/CaptainResource tests plus Captain/Copilot focused handler/service tests passed. Next slice is B11.1c documents, assistant responses, bulk actions, and custom-tool test execution. - 2026-06-05: B11.1c Captain document/response/action checkpoint prepared as `feat(captain): align document response actions`; documents now return Chatwoot raw/list payloads with account scope and `202` sync marking, assistant responses now support nested bodies, filters, raw serializers, edited tracking, and `204` delete, bulk actions now accept Chatwoot `AssistantResponse`/`AssistantDocument` resource payloads, and custom-tool test now executes unsaved configs through a fakeable HTTP boundary returning `{ status, body }`. Focused B11.1c handler tests, Captain/Copilot service tests, Captain repository tests, handler/service package tests, escalated full `go test ./...`, and `git diff --check` passed. Next slice is B11.2 Copilot persistence and safe LLM/document gates. - 2026-06-05: B11.2 Copilot thread/message checkpoint prepared as `feat(copilot): align thread message payloads`; Copilot threads now accept Chatwoot `{ message, assistant_id, conversation_id }`, validate account-scoped assistants, create initial user plus safe no-LLM assistant messages, and return raw thread serializers. Nested Copilot messages now list/create raw message payloads with embedded thread push data, current account/user scoping, ascending message order, and no local envelopes. Focused Copilot handler/service/repository tests, Captain/Copilot handler/service tests, handler/service/repository package tests, escalated full `go test ./...`, and `git diff --check` passed. Next slice is B11.3 document sync/indexing, Meilisearch/embedding gates, Copilot tasks/preferences/tool-call depth, and streaming/realtime compatibility. +- 2026-06-05: B11.3a Captain preferences checkpoint prepared as `feat(captain): align preferences payloads`; Captain preferences now use Chatwoot's raw `{ providers, models, features }` show/update payload, persist `captain_models`/`captain_features` on accounts, merge partial preference updates, reject invalid model selections with `422`, gate updates to administrators, and expose exact no-trailing-slash GET/PUT preference routes. Focused CaptainPreference handler/service tests, Captain/Copilot handler/service tests, handler/service/model/router package tests, migration tests, route dump/parity regeneration, escalated full `go test ./...`, and `git diff --check` passed. Next slice is B11.3b document sync/indexing, Meilisearch/embedding gates, Copilot tasks/tool-call depth, and streaming/realtime compatibility. diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index 21cf1488..8ef245ec 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -24,7 +24,6 @@ DELETE /api/v1/accounts/:account_id/captain/assistants/:assistant_id/scenarios/: DELETE /api/v1/accounts/:account_id/captain/copilot_threads/:thread_id DELETE /api/v1/accounts/:account_id/captain/custom_tools/:tool_id DELETE /api/v1/accounts/:account_id/captain/documents/:document_id -DELETE /api/v1/accounts/:account_id/captain/preferences/ DELETE /api/v1/accounts/:account_id/captain/scenarios/:scenario_id DELETE /api/v1/accounts/:account_id/channels/facebook_channel/:fb_id DELETE /api/v1/accounts/:account_id/companies/:company_id @@ -169,6 +168,7 @@ GET /api/v1/accounts/:account_id/captain/custom_tools/ GET /api/v1/accounts/:account_id/captain/custom_tools/:tool_id GET /api/v1/accounts/:account_id/captain/documents/ GET /api/v1/accounts/:account_id/captain/documents/:document_id +GET /api/v1/accounts/:account_id/captain/preferences GET /api/v1/accounts/:account_id/captain/preferences/ GET /api/v1/accounts/:account_id/captain/scenarios/ GET /api/v1/accounts/:account_id/captain/scenarios/:scenario_id @@ -535,7 +535,6 @@ POST /api/v1/accounts/:account_id/captain/custom_tools/ POST /api/v1/accounts/:account_id/captain/custom_tools/test POST /api/v1/accounts/:account_id/captain/documents/ POST /api/v1/accounts/:account_id/captain/documents/:document_id/sync -POST /api/v1/accounts/:account_id/captain/preferences/ POST /api/v1/accounts/:account_id/captain/scenarios/ POST /api/v1/accounts/:account_id/captain/tasks/follow_up POST /api/v1/accounts/:account_id/captain/tasks/label_suggestion @@ -771,6 +770,7 @@ PUT /api/v1/accounts/:account_id/captain/assistant_responses/:response_id PUT /api/v1/accounts/:account_id/captain/assistants/:assistant_id PUT /api/v1/accounts/:account_id/captain/assistants/:assistant_id/scenarios/:scenario_id PUT /api/v1/accounts/:account_id/captain/custom_tools/:tool_id +PUT /api/v1/accounts/:account_id/captain/preferences PUT /api/v1/accounts/:account_id/captain/preferences/ PUT /api/v1/accounts/:account_id/captain/scenarios/:scenario_id PUT /api/v1/accounts/:account_id/companies/:company_id diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index d6371cdf..0db6d84e 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -567,7 +567,7 @@ func Bootstrap(env string) (*App, error) { copilotContextService := service.NewCopilotContextService(messageRepo, conversationRepo, contactRepo, llmProvider) captainTaskService := service.NewCaptainTaskService(captainAssistantRepo, captainAssistantResponseRepo, captainCustomToolRepo, conversationRepo, messageRepo, llmProvider, copilotContextService) conversationInsightService := service.NewConversationInsightService(conversationRepo, messageRepo, captainAssistantRepo, llmProvider) - captainPreferenceService := service.NewCaptainPreferenceService(captainPreferenceRepo) + captainPreferenceService := service.NewCaptainPreferenceService(captainPreferenceRepo, accountRepo) captainTaskExtendedService := service.NewCaptainTaskExtendedService(conversationRepo, messageRepo, captainAssistantRepo, captainPreferenceRepo, llmProvider) captainAssistantResponseService := service.NewCaptainAssistantResponseService(captainAssistantRepo, captainAssistantResponseRepo, conversationRepo, messageRepo, captainPreferenceRepo, llmProvider) captainBulkActionService := service.NewCaptainBulkActionService(conversationRepo, messageRepo, captainAssistantRepo, captainPreferenceRepo, llmProvider, captainTaskExtendedService, captainAssistantResponseService) diff --git a/internal/handler/api/v1/captain_preference_handler.go b/internal/handler/api/v1/captain_preference_handler.go index 1a403f9c..c27152db 100644 --- a/internal/handler/api/v1/captain_preference_handler.go +++ b/internal/handler/api/v1/captain_preference_handler.go @@ -2,7 +2,6 @@ package v1 import ( "net/http" - "strconv" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/service" @@ -23,8 +22,8 @@ func NewCaptainPreferenceHandler(svc *service.CaptainPreferenceService) *Captain // Create creates a new captain preference for an account. // POST /api/v1/accounts/:id/captain/preferences func (h *CaptainPreferenceHandler) Create(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } @@ -35,7 +34,7 @@ func (h *CaptainPreferenceHandler) Create(c *gin.Context) { return } - pref, err := h.svc.Create(c.Request.Context(), uint(accountID), &req) + pref, err := h.svc.Create(c.Request.Context(), accountID, &req) if err != nil { applogger.L().Errorf("Create captain preference: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create preference") @@ -48,57 +47,61 @@ func (h *CaptainPreferenceHandler) Create(c *gin.Context) { // Get retrieves the captain preference for an account. // GET /api/v1/accounts/:id/captain/preferences func (h *CaptainPreferenceHandler) Get(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } - pref, err := h.svc.Get(c.Request.Context(), uint(accountID)) + pref, err := h.svc.GetConfig(c.Request.Context(), accountID) if err != nil { applogger.L().Errorf("Get captain preference: %v", err) response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "preference not found") return } - response.OK(c, pref) + c.JSON(http.StatusOK, pref) } // Update updates the captain preference for an account. // PUT /api/v1/accounts/:id/captain/preferences func (h *CaptainPreferenceHandler) Update(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } + if !captainPreferencesCanUpdate(c) { + response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "administrator role required") + return + } - var req service.UpdatePreferenceRequest + var req service.UpdateCaptainConfigRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } - pref, err := h.svc.Update(c.Request.Context(), uint(accountID), &req) + pref, err := h.svc.UpdateConfig(c.Request.Context(), accountID, &req) if err != nil { applogger.L().Errorf("Update captain preference: %v", err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update preference") + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) return } - response.OK(c, pref) + c.JSON(http.StatusOK, pref) } // Delete removes the captain preference for an account. // DELETE /api/v1/accounts/:id/captain/preferences func (h *CaptainPreferenceHandler) Delete(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } - if err := h.svc.Delete(c.Request.Context(), uint(accountID)); err != nil { + if err := h.svc.Delete(c.Request.Context(), accountID); err != nil { applogger.L().Errorf("Delete captain preference: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete preference") return @@ -106,3 +109,8 @@ func (h *CaptainPreferenceHandler) Delete(c *gin.Context) { response.OK(c, gin.H{"message": "preference deleted"}) } + +func captainPreferencesCanUpdate(c *gin.Context) bool { + role := getRole(c) + return role == "administrator" || role == "super_admin" +} diff --git a/internal/handler/api/v1/captain_preference_handler_test.go b/internal/handler/api/v1/captain_preference_handler_test.go index dfff4d5e..0f2e0160 100644 --- a/internal/handler/api/v1/captain_preference_handler_test.go +++ b/internal/handler/api/v1/captain_preference_handler_test.go @@ -3,131 +3,149 @@ package v1 import ( "bytes" "encoding/json" - "fmt" "net/http" "net/http/httptest" + "strconv" "testing" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" + "github.com/stretchr/testify/require" + "gorm.io/datatypes" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) -type CaptainPreferenceHandlerTestSuite struct { - suite.Suite +type captainPreferenceFixture struct { db *gorm.DB - handler *CaptainPreferenceHandler + router *gin.Engine account *model.Account } -func (s *CaptainPreferenceHandlerTestSuite) SetupSuite() { +func newCaptainPreferenceFixture(t *testing.T) *captainPreferenceFixture { + t.Helper() gin.SetMode(gin.TestMode) - db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.CaptainPreference{})) + + account := &model.Account{Name: "Captain Preferences", Active: true, CaptainModels: datatypes.JSON(`{}`), CaptainFeatures: datatypes.JSON(`{}`)} + require.NoError(t, db.Create(account).Error) + + prefRepo := repository.NewCaptainPreferenceRepo(db) + accountRepo := repository.NewAccountRepo(db) + handler := NewCaptainPreferenceHandler(service.NewCaptainPreferenceService(prefRepo, accountRepo)) + router := gin.New() + router.GET("/api/v1/accounts/:account_id/captain/preferences", handler.Get) + router.PUT("/api/v1/accounts/:account_id/captain/preferences", func(c *gin.Context) { + c.Set("role", "administrator") + handler.Update(c) + }) + router.PUT("/api/v1/accounts/:account_id/captain/preferences/as-agent", func(c *gin.Context) { + c.Set("role", "agent") + handler.Update(c) }) - s.Require().NoError(err) - s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.CaptainPreference{})) - s.db = db - repo := repository.NewCaptainPreferenceRepo(db) - svc := service.NewCaptainPreferenceService(repo) - s.handler = NewCaptainPreferenceHandler(svc) - - s.account = &model.Account{Name: "test-captain-pref-account"} - s.Require().NoError(db.Create(s.account).Error) + t.Cleanup(func() { + sqlDB, dbErr := db.DB() + require.NoError(t, dbErr) + require.NoError(t, sqlDB.Close()) + }) + return &captainPreferenceFixture{db: db, router: router, account: account} } -func (s *CaptainPreferenceHandlerTestSuite) TearDownSuite() { - if s.db != nil { - sqlDB, _ := s.db.DB() - sqlDB.Close() +func (f *captainPreferenceFixture) path(suffix string) string { + return "/api/v1/accounts/" + strconv.FormatUint(uint64(f.account.ID), 10) + "/captain/preferences" + suffix +} + +func (f *captainPreferenceFixture) request(method, path string, body any) *httptest.ResponseRecorder { + var raw []byte + if body != nil { + raw, _ = json.Marshal(body) } -} - -func TestCaptainPreferenceHandlerSuite(t *testing.T) { - suite.Run(t, new(CaptainPreferenceHandlerTestSuite)) -} - -func (s *CaptainPreferenceHandlerTestSuite) TestCreate_BadRequest_InvalidAccountID() { - r := gin.New() - r.POST("/api/v1/accounts/:id/captain/preferences", s.handler.Create) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/captain/preferences", nil) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - - assert.Equal(s.T(), http.StatusBadRequest, w.Code) -} - -func (s *CaptainPreferenceHandlerTestSuite) TestCreate_BadRequest_EmptyBody() { - r := gin.New() - r.POST("/api/v1/accounts/:id/captain/preferences", s.handler.Create) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/captain/preferences", s.account.ID), nil) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - - assert.Equal(s.T(), http.StatusBadRequest, w.Code) -} - -func (s *CaptainPreferenceHandlerTestSuite) TestCreate_Success() { - r := gin.New() - r.POST("/api/v1/accounts/:id/captain/preferences", s.handler.Create) - - body := map[string]interface{}{ - "tone": "professional", - "language": "en", - "auto_label_enabled": true, - "auto_follow_up_enabled": false, + recorder := httptest.NewRecorder() + req, _ := http.NewRequest(method, path, bytes.NewReader(raw)) + if body != nil { + req.Header.Set("Content-Type", "application/json") } - b, _ := json.Marshal(body) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/captain/preferences", s.account.ID), bytes.NewBuffer(b)) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - - assert.Equal(s.T(), http.StatusOK, w.Code) + f.router.ServeHTTP(recorder, req) + return recorder } -func (s *CaptainPreferenceHandlerTestSuite) TestGet_BadRequest_InvalidAccountID() { - r := gin.New() - r.GET("/api/v1/accounts/:id/captain/preferences", s.handler.Get) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/api/v1/accounts/abc/captain/preferences", nil) - r.ServeHTTP(w, req) - - assert.Equal(s.T(), http.StatusBadRequest, w.Code) +func decodeCaptainPreferencePayload(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any { + t.Helper() + var payload map[string]any + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload)) + return payload } -func (s *CaptainPreferenceHandlerTestSuite) TestUpdate_BadRequest_InvalidAccountID() { - r := gin.New() - r.PUT("/api/v1/accounts/:id/captain/preferences", s.handler.Update) +func TestCaptainPreferencesGetReturnsRawChatwootConfig(t *testing.T) { + f := newCaptainPreferenceFixture(t) - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", "/api/v1/accounts/abc/captain/preferences", nil) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) + w := f.request(http.MethodGet, f.path(""), nil) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + payload := decodeCaptainPreferencePayload(t, w) + require.Nil(t, payload["success"]) + require.Contains(t, payload, "providers") + require.Contains(t, payload, "models") + require.Contains(t, payload, "features") - assert.Equal(s.T(), http.StatusBadRequest, w.Code) + features := payload["features"].(map[string]any) + editor := features["editor"].(map[string]any) + require.Equal(t, false, editor["enabled"]) + require.Equal(t, "gpt-4.1-mini", editor["default"]) + require.Equal(t, "gpt-4.1-mini", editor["selected"]) + require.NotEmpty(t, editor["models"].([]any)) } -func (s *CaptainPreferenceHandlerTestSuite) TestDelete_BadRequest_InvalidAccountID() { - r := gin.New() - r.DELETE("/api/v1/accounts/:id/captain/preferences", s.handler.Delete) +func TestCaptainPreferencesUpdateMergesAccountModelsAndFeatures(t *testing.T) { + f := newCaptainPreferenceFixture(t) + require.NoError(t, f.db.Model(&model.Account{}).Where("id = ?", f.account.ID).Updates(map[string]any{ + "captain_models": datatypes.JSON(`{"editor":"gpt-4.1-mini","assistant":"gpt-5.1"}`), + "captain_features": datatypes.JSON(`{"editor":true,"assistant":false}`), + }).Error) - w := httptest.NewRecorder() - req, _ := http.NewRequest("DELETE", "/api/v1/accounts/abc/captain/preferences", nil) - r.ServeHTTP(w, req) + w := f.request(http.MethodPut, f.path(""), map[string]any{ + "captain_models": map[string]any{"editor": "gpt-4.1"}, + "captain_features": map[string]any{"editor": false}, + }) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + payload := decodeCaptainPreferencePayload(t, w) + features := payload["features"].(map[string]any) + require.Equal(t, "gpt-4.1", features["editor"].(map[string]any)["selected"]) + require.Equal(t, false, features["editor"].(map[string]any)["enabled"]) + require.Equal(t, "gpt-5.1", features["assistant"].(map[string]any)["selected"]) + require.Equal(t, false, features["assistant"].(map[string]any)["enabled"]) - assert.Equal(s.T(), http.StatusBadRequest, w.Code) -} \ No newline at end of file + var account model.Account + require.NoError(t, f.db.First(&account, f.account.ID).Error) + var models map[string]string + var featureValues map[string]bool + require.NoError(t, json.Unmarshal(account.CaptainModels, &models)) + require.NoError(t, json.Unmarshal(account.CaptainFeatures, &featureValues)) + require.Equal(t, "gpt-4.1", models["editor"]) + require.Equal(t, "gpt-5.1", models["assistant"]) + require.False(t, featureValues["editor"]) + require.False(t, featureValues["assistant"]) +} + +func TestCaptainPreferencesUpdateRejectsNonAdminAndInvalidModel(t *testing.T) { + f := newCaptainPreferenceFixture(t) + + w := f.request(http.MethodPut, f.path("/as-agent"), map[string]any{"captain_models": map[string]any{"editor": "gpt-4.1"}}) + require.Equal(t, http.StatusUnauthorized, w.Code, w.Body.String()) + + w = f.request(http.MethodPut, f.path(""), map[string]any{"captain_models": map[string]any{"editor": "not-a-model"}}) + require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String()) + require.Contains(t, decodeCaptainPreferencePayload(t, w)["error"], "not a valid model") +} + +func TestCaptainPreferencesInvalidAccountID(t *testing.T) { + f := newCaptainPreferenceFixture(t) + + w := f.request(http.MethodGet, "/api/v1/accounts/abc/captain/preferences", nil) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) +} diff --git a/internal/model/account.go b/internal/model/account.go index 6726d665..afb1fbee 100644 --- a/internal/model/account.go +++ b/internal/model/account.go @@ -1,19 +1,23 @@ package model +import "gorm.io/datatypes" + // Account represents a tenant/organization in the system. type Account struct { Base - Name string `gorm:"size:255;not null" json:"name"` - Domain string `gorm:"size:255" json:"domain,omitempty"` - Locale string `gorm:"size:10;default:en" json:"locale"` - Timezone string `gorm:"size:50;default:UTC" json:"timezone"` - Active bool `gorm:"default:true" json:"active"` - Status string `gorm:"size:50;default:active" json:"status"` - OnboardingStep string `gorm:"size:100" json:"onboarding_step,omitempty"` - FeatureFlags string `gorm:"type:text" json:"feature_flags,omitempty"` // JSON-encoded feature flags - AutoResolveDuration int `gorm:"default:0" json:"auto_resolve_duration,omitempty"` // days - AgentLimit int `gorm:"default:0" json:"agent_limit,omitempty"` // max agents allowed (0 = unlimited), Chatwoot usage_limits[:agents] - InboxLimit int `gorm:"default:0" json:"inbox_limit,omitempty"` // max inboxes allowed (0 = unlimited), Chatwoot usage_limits[:inboxes] + Name string `gorm:"size:255;not null" json:"name"` + Domain string `gorm:"size:255" json:"domain,omitempty"` + Locale string `gorm:"size:10;default:en" json:"locale"` + Timezone string `gorm:"size:50;default:UTC" json:"timezone"` + Active bool `gorm:"default:true" json:"active"` + Status string `gorm:"size:50;default:active" json:"status"` + OnboardingStep string `gorm:"size:100" json:"onboarding_step,omitempty"` + FeatureFlags string `gorm:"type:text" json:"feature_flags,omitempty"` // JSON-encoded feature flags + AutoResolveDuration int `gorm:"default:0" json:"auto_resolve_duration,omitempty"` // days + AgentLimit int `gorm:"default:0" json:"agent_limit,omitempty"` // max agents allowed (0 = unlimited), Chatwoot usage_limits[:agents] + InboxLimit int `gorm:"default:0" json:"inbox_limit,omitempty"` // max inboxes allowed (0 = unlimited), Chatwoot usage_limits[:inboxes] + CaptainModels datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"captain_models,omitempty"` + CaptainFeatures datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"captain_features,omitempty"` } func (Account) TableName() string { return "accounts" } diff --git a/internal/router/router.go b/internal/router/router.go index 37f5720f..05682811 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -1265,10 +1265,10 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // Reference: Chatwoot Captain::PreferencesController preferences := captain.Group("/preferences") { - preferences.POST("/", h.CaptainPreference.Create) + preferences.GET("", h.CaptainPreference.Get) preferences.GET("/", h.CaptainPreference.Get) + preferences.PUT("", h.CaptainPreference.Update) preferences.PUT("/", h.CaptainPreference.Update) - preferences.DELETE("/", h.CaptainPreference.Delete) } // M12: Label Suggestion + Follow Up (GET endpoints) diff --git a/internal/service/captain_preference_service.go b/internal/service/captain_preference_service.go index b7bacc44..2ecc41ef 100644 --- a/internal/service/captain_preference_service.go +++ b/internal/service/captain_preference_service.go @@ -2,22 +2,30 @@ package service import ( "context" + "encoding/json" "fmt" + "strings" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" - pkgvalidator "github.com/gochat/gochat/pkg/validator" applogger "github.com/gochat/gochat/pkg/logger" + pkgvalidator "github.com/gochat/gochat/pkg/validator" + "gorm.io/datatypes" ) // CaptainPreferenceService implements business logic for CaptainPreference operations. // Reference: Chatwoot enterprise/app/controllers/api/v1/captain/preferences_controller.rb type CaptainPreferenceService struct { - repo *repository.CaptainPreferenceRepo + repo *repository.CaptainPreferenceRepo + accountRepo *repository.AccountRepo } -func NewCaptainPreferenceService(repo *repository.CaptainPreferenceRepo) *CaptainPreferenceService { - return &CaptainPreferenceService{repo: repo} +func NewCaptainPreferenceService(repo *repository.CaptainPreferenceRepo, accountRepo ...*repository.AccountRepo) *CaptainPreferenceService { + svc := &CaptainPreferenceService{repo: repo} + if len(accountRepo) > 0 { + svc.accountRepo = accountRepo[0] + } + return svc } // --- Request DTOs --- @@ -25,29 +33,106 @@ func NewCaptainPreferenceService(repo *repository.CaptainPreferenceRepo) *Captai // CreatePreferenceRequest is the DTO for creating a preference. // Reference: Chatwoot enterprise/app/models/captain/preference.rb type CreatePreferenceRequest struct { - Tone string `json:"tone" validate:"omitempty,oneof=professional casual friendly formal"` - Language string `json:"language" validate:"omitempty,min=1,max=10"` - ResponseGuidelines string `json:"response_guidelines" validate:"omitempty,max=2000"` - AutoLabelEnabled bool `json:"auto_label_enabled"` - AutoFollowUpEnabled bool `json:"auto_follow_up_enabled"` - AutoReplyEnabled bool `json:"auto_reply_enabled"` - MaxResponseLength int `json:"max_response_length" validate:"omitempty,min=50,max=5000"` - CustomPromptSuffix string `json:"custom_prompt_suffix" validate:"omitempty,max=1000"` + Tone string `json:"tone" validate:"omitempty,oneof=professional casual friendly formal"` + Language string `json:"language" validate:"omitempty,min=1,max=10"` + ResponseGuidelines string `json:"response_guidelines" validate:"omitempty,max=2000"` + AutoLabelEnabled bool `json:"auto_label_enabled"` + AutoFollowUpEnabled bool `json:"auto_follow_up_enabled"` + AutoReplyEnabled bool `json:"auto_reply_enabled"` + MaxResponseLength int `json:"max_response_length" validate:"omitempty,min=50,max=5000"` + CustomPromptSuffix string `json:"custom_prompt_suffix" validate:"omitempty,max=1000"` } // UpdatePreferenceRequest is the DTO for updating a preference. // Reference: Chatwoot enterprise/app/models/captain/preference.rb type UpdatePreferenceRequest struct { - Tone string `json:"tone,omitempty" validate:"omitempty,oneof=professional casual friendly formal"` - Language string `json:"language,omitempty" validate:"omitempty,min=1,max=10"` - ResponseGuidelines string `json:"response_guidelines,omitempty" validate:"omitempty,max=2000"` - AutoLabelEnabled *bool `json:"auto_label_enabled,omitempty"` - AutoFollowUpEnabled *bool `json:"auto_follow_up_enabled,omitempty"` - AutoReplyEnabled *bool `json:"auto_reply_enabled,omitempty"` - MaxResponseLength *int `json:"max_response_length,omitempty" validate:"omitempty,min=50,max=5000"` - CustomPromptSuffix string `json:"custom_prompt_suffix,omitempty" validate:"omitempty,max=1000"` + Tone string `json:"tone,omitempty" validate:"omitempty,oneof=professional casual friendly formal"` + Language string `json:"language,omitempty" validate:"omitempty,min=1,max=10"` + ResponseGuidelines string `json:"response_guidelines,omitempty" validate:"omitempty,max=2000"` + AutoLabelEnabled *bool `json:"auto_label_enabled,omitempty"` + AutoFollowUpEnabled *bool `json:"auto_follow_up_enabled,omitempty"` + AutoReplyEnabled *bool `json:"auto_reply_enabled,omitempty"` + MaxResponseLength *int `json:"max_response_length,omitempty" validate:"omitempty,min=50,max=5000"` + CustomPromptSuffix string `json:"custom_prompt_suffix,omitempty" validate:"omitempty,max=1000"` } +// UpdateCaptainConfigRequest matches Chatwoot's Captain::PreferencesController params. +type UpdateCaptainConfigRequest struct { + CaptainModels map[string]string `json:"captain_models"` + CaptainFeatures map[string]bool `json:"captain_features"` +} + +// CaptainConfigPayload is the raw payload returned by Chatwoot preferences show/update. +type CaptainConfigPayload struct { + Providers map[string]map[string]string `json:"providers"` + Models map[string]CaptainModelConfig `json:"models"` + Features map[string]CaptainFeatureConfig `json:"features"` +} + +type CaptainModelConfig struct { + Provider string `json:"provider"` + DisplayName string `json:"display_name"` + ComingSoon *bool `json:"coming_soon,omitempty"` + CreditMultiplier int `json:"credit_multiplier"` +} + +type CaptainFeatureConfig struct { + Models []CaptainFeatureModel `json:"models"` + Default string `json:"default"` + Enabled bool `json:"enabled"` + Selected string `json:"selected"` +} + +type CaptainFeatureModel struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + Provider string `json:"provider"` + ComingSoon *bool `json:"coming_soon"` + CreditMultiplier int `json:"credit_multiplier"` +} + +var captainProviders = map[string]map[string]string{ + "openai": {"display_name": "OpenAI"}, + "anthropic": {"display_name": "Anthropic"}, + "gemini": {"display_name": "Gemini"}, +} + +var captainModels = map[string]CaptainModelConfig{ + "gpt-4.1": {Provider: "openai", DisplayName: "GPT-4.1", CreditMultiplier: 3}, + "gpt-4.1-mini": {Provider: "openai", DisplayName: "GPT-4.1 Mini", CreditMultiplier: 1}, + "gpt-4.1-nano": {Provider: "openai", DisplayName: "GPT-4.1 Nano", CreditMultiplier: 1}, + "gpt-5.1": {Provider: "openai", DisplayName: "GPT-5.1", CreditMultiplier: 2}, + "gpt-5-mini": {Provider: "openai", DisplayName: "GPT-5 Mini", CreditMultiplier: 1}, + "gpt-5-nano": {Provider: "openai", DisplayName: "GPT-5 Nano", CreditMultiplier: 1}, + "gpt-5.2": {Provider: "openai", DisplayName: "GPT-5.2", CreditMultiplier: 3}, + "claude-haiku-4.5": {Provider: "anthropic", DisplayName: "Claude Haiku 4.5", ComingSoon: boolPtr(true), CreditMultiplier: 2}, + "claude-sonnet-4.5": {Provider: "anthropic", DisplayName: "Claude Sonnet 4.5", ComingSoon: boolPtr(true), CreditMultiplier: 3}, + "gemini-3-flash": {Provider: "gemini", DisplayName: "Gemini 3 Flash", ComingSoon: boolPtr(true), CreditMultiplier: 1}, + "gemini-3-pro": {Provider: "gemini", DisplayName: "Gemini 3 Pro", ComingSoon: boolPtr(true), CreditMultiplier: 3}, + "whisper-1": {Provider: "openai", DisplayName: "Whisper", CreditMultiplier: 1}, + "text-embedding-3-small": {Provider: "openai", DisplayName: "Text Embedding 3 Small", CreditMultiplier: 1}, +} + +var captainFeatureModels = map[string][]string{ + "editor": {"gpt-4.1-mini", "gpt-4.1-nano", "gpt-5-mini", "gpt-4.1", "gpt-5.1", "gpt-5.2", "claude-haiku-4.5", "gemini-3-flash", "gemini-3-pro"}, + "assistant": {"gpt-5-mini", "gpt-4.1", "gpt-5.1", "gpt-5.2", "claude-haiku-4.5", "claude-sonnet-4.5", "gemini-3-flash", "gemini-3-pro"}, + "copilot": {"gpt-5-mini", "gpt-4.1", "gpt-5.1", "gpt-5.2", "claude-haiku-4.5", "claude-sonnet-4.5", "gemini-3-flash", "gemini-3-pro"}, + "label_suggestion": {"gpt-4.1-nano", "gpt-4.1-mini", "gpt-5-mini", "gemini-3-flash", "claude-haiku-4.5"}, + "audio_transcription": {"whisper-1"}, + "help_center_search": {"text-embedding-3-small"}, +} + +var captainFeatureDefaults = map[string]string{ + "editor": "gpt-4.1-mini", + "assistant": "gpt-5.1", + "copilot": "gpt-5.1", + "label_suggestion": "gpt-4.1-nano", + "audio_transcription": "whisper-1", + "help_center_search": "text-embedding-3-small", +} + +var captainFeatureOrder = []string{"editor", "assistant", "copilot", "label_suggestion", "audio_transcription", "help_center_search"} + // --- Business logic --- // validTones are the allowed tone values for captain preferences. @@ -64,6 +149,138 @@ func isValidTone(tone string) bool { return false } +// GetConfig returns Chatwoot's raw Captain preferences payload for an account. +func (s *CaptainPreferenceService) GetConfig(ctx context.Context, accountID uint) (*CaptainConfigPayload, error) { + account, err := s.findAccount(ctx, accountID) + if err != nil { + return nil, err + } + return captainConfigPayload(account), nil +} + +// UpdateConfig merges captain_models/captain_features into account settings and returns the raw payload. +func (s *CaptainPreferenceService) UpdateConfig(ctx context.Context, accountID uint, req *UpdateCaptainConfigRequest) (*CaptainConfigPayload, error) { + account, err := s.findAccount(ctx, accountID) + if err != nil { + return nil, err + } + + models := jsonMapString(account.CaptainModels) + features := jsonMapBool(account.CaptainFeatures) + + for key, value := range req.CaptainModels { + if !isCaptainFeature(key) { + continue + } + if strings.TrimSpace(value) == "" { + models[key] = "" + continue + } + if !validCaptainModelFor(key, value) { + return nil, fmt.Errorf("'%s' is not a valid model for %s. Allowed: %s", value, key, strings.Join(captainFeatureModels[key], ", ")) + } + models[key] = value + } + for key, value := range req.CaptainFeatures { + if !isCaptainFeature(key) { + continue + } + features[key] = value + } + + account.CaptainModels = marshalJSONMap(models) + account.CaptainFeatures = marshalJSONMap(features) + if err := s.accountRepo.Update(ctx, account); err != nil { + return nil, fmt.Errorf("update captain preferences: %w", err) + } + return captainConfigPayload(account), nil +} + +func (s *CaptainPreferenceService) findAccount(ctx context.Context, accountID uint) (*model.Account, error) { + if s.accountRepo == nil { + return nil, fmt.Errorf("account repository is required for captain preferences") + } + account, err := s.accountRepo.FindByID(ctx, accountID) + if err != nil { + return nil, fmt.Errorf("account not found: %w", err) + } + return account, nil +} + +func captainConfigPayload(account *model.Account) *CaptainConfigPayload { + accountModels := jsonMapString(account.CaptainModels) + accountFeatures := jsonMapBool(account.CaptainFeatures) + features := make(map[string]CaptainFeatureConfig, len(captainFeatureOrder)) + for _, key := range captainFeatureOrder { + models := make([]CaptainFeatureModel, 0, len(captainFeatureModels[key])) + for _, modelName := range captainFeatureModels[key] { + modelConfig := captainModels[modelName] + models = append(models, CaptainFeatureModel{ + ID: modelName, + DisplayName: modelConfig.DisplayName, + Provider: modelConfig.Provider, + ComingSoon: modelConfig.ComingSoon, + CreditMultiplier: modelConfig.CreditMultiplier, + }) + } + selected := accountModels[key] + if !validCaptainModelFor(key, selected) { + selected = captainFeatureDefaults[key] + } + features[key] = CaptainFeatureConfig{ + Models: models, + Default: captainFeatureDefaults[key], + Enabled: accountFeatures[key], + Selected: selected, + } + } + return &CaptainConfigPayload{Providers: captainProviders, Models: captainModels, Features: features} +} + +func isCaptainFeature(key string) bool { + _, ok := captainFeatureModels[key] + return ok +} + +func validCaptainModelFor(feature, modelName string) bool { + if modelName == "" { + return false + } + for _, allowed := range captainFeatureModels[feature] { + if allowed == modelName { + return true + } + } + return false +} + +func jsonMapString(raw datatypes.JSON) map[string]string { + result := map[string]string{} + if len(raw) == 0 || string(raw) == "null" { + return result + } + _ = json.Unmarshal(raw, &result) + return result +} + +func jsonMapBool(raw datatypes.JSON) map[string]bool { + result := map[string]bool{} + if len(raw) == 0 || string(raw) == "null" { + return result + } + _ = json.Unmarshal(raw, &result) + return result +} + +func marshalJSONMap(value any) datatypes.JSON { + raw, _ := json.Marshal(value) + return datatypes.JSON(raw) +} + +func boolPtr(value bool) *bool { + return &value +} + // Create creates a new preference for an account. func (s *CaptainPreferenceService) Create(ctx context.Context, accountID uint, req *CreatePreferenceRequest) (*model.CaptainPreference, error) { // Validate request @@ -78,15 +295,15 @@ func (s *CaptainPreferenceService) Create(ctx context.Context, accountID uint, r } pref := &model.CaptainPreference{ - AccountID: accountID, - Tone: req.Tone, - Language: req.Language, - ResponseGuidelines: req.ResponseGuidelines, - AutoLabelEnabled: req.AutoLabelEnabled, + AccountID: accountID, + Tone: req.Tone, + Language: req.Language, + ResponseGuidelines: req.ResponseGuidelines, + AutoLabelEnabled: req.AutoLabelEnabled, AutoFollowUpEnabled: req.AutoFollowUpEnabled, - AutoReplyEnabled: req.AutoReplyEnabled, - MaxResponseLength: req.MaxResponseLength, - CustomPromptSuffix: req.CustomPromptSuffix, + AutoReplyEnabled: req.AutoReplyEnabled, + MaxResponseLength: req.MaxResponseLength, + CustomPromptSuffix: req.CustomPromptSuffix, } // Apply defaults diff --git a/migrations/000025_add_account_captain_preferences.down.sql b/migrations/000025_add_account_captain_preferences.down.sql new file mode 100644 index 00000000..dff0f78e --- /dev/null +++ b/migrations/000025_add_account_captain_preferences.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE accounts + DROP COLUMN IF EXISTS captain_features, + DROP COLUMN IF EXISTS captain_models; diff --git a/migrations/000025_add_account_captain_preferences.up.sql b/migrations/000025_add_account_captain_preferences.up.sql new file mode 100644 index 00000000..a98645f9 --- /dev/null +++ b/migrations/000025_add_account_captain_preferences.up.sql @@ -0,0 +1,5 @@ +-- Chatwoot stores Captain model/feature preferences in account settings. + +ALTER TABLE accounts + ADD COLUMN IF NOT EXISTS captain_models JSONB NOT NULL DEFAULT '{}', + ADD COLUMN IF NOT EXISTS captain_features JSONB NOT NULL DEFAULT '{}';