diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 5c6ba8fa..8bc4e9fa 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(captain): align scenario and tool payloads`. -- Latest documentation checkpoint: this checkpoint, recorded with the B11.1b Captain scenario/custom-tool implementation. -- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b now aligns Captain scenarios and custom tools. Next active slice is B11.1c Captain documents, assistant responses, and bulk actions payload depth. +- Latest implementation checkpoint: `feat(captain): align document response actions`. +- Latest documentation checkpoint: this checkpoint, recorded with the B11.1c Captain document/assistant-response/bulk-action/custom-tool-test 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. Next active slice is B11.2 Copilot persistence and safe LLM/document 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`. @@ -148,12 +148,13 @@ This ledger records the committed parity checkpoints that future slices should b | `feat(inboxes): enforce chatwoot inbox limits` | Completed B10.4 account-level inbox limit parity: `accounts.inbox_limit` now represents Chatwoot `usage_limits[:inboxes]`, `InboxService.EnsureCanCreateInbox` blocks over-limit creates before persistence, generic inbox and dedicated channel create paths return `402 { error: "Account limit exceeded. Upgrade to a higher plan" }`, and LINE/email/Twilio plus other channel handlers avoid orphan channel rows. | `go test ./internal/handler/api/v1 -run 'Inbox\|Channel\|Capacity\|Limit' -count=1`; `go test ./internal/service -run 'Inbox\|Channel\|Capacity\|Limit' -count=1`; handler and service packages passed; full verification recorded below. No route changes; route dump remains `TOTAL: 830`. | Continue B11 Captain/Copilot persistence, safe disabled states, and frontend payload parity. | | `feat(captain): align assistant resources` | Completed B11.1a Captain assistant resource parity for the reused dashboard assistant client: assistant list/show/create/update/delete now use raw Chatwoot/Jbuilder payloads, nested `{ assistant: ... }` bodies are accepted, assistant access is account-scoped, `/captain/assistants/tools` returns the built-in tool array, and assistant inbox bind/list/delete routes use Chatwoot `{ payload, meta }`, raw inbox, and `204` contracts. | `go test ./internal/handler/api/v1 -run CaptainAssistant -count=1`; `go test ./internal/service -run CaptainAssistant -count=1`; route dump/parity regenerated with unchanged `TOTAL: 830`; focused handler fixtures cover account scoping, tools, inbox binding, and no local `{ success, data }` envelopes. | Continue B11.1b with Captain documents, scenarios, assistant responses, bulk actions, and custom tools payload depth; B11 stays Doing until Copilot, LLM/document gates, and smoke coverage land. | | `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. | ## Next Slice Contract -Completed implementation slice: B11.1b now aligns Captain scenarios and custom tools with Chatwoot frontend payloads, building on B11.1a assistant resources. +Completed implementation slice: B11.1c now aligns Captain documents, assistant responses, bulk actions, and custom-tool test execution with Chatwoot frontend payloads, building on B11.1a-B11.1b assistant/scenario/tool resources. -Next implementation slice: continue B11.1c Captain documents, assistant responses, bulk actions, and custom-tool test execution before moving to B11.2 Copilot persistence and safe LLM/document gates. B9.3 delayed/durable worker scheduling and B9.4 macro attachment depth remain named Phase 5 follow-ups. +Next implementation slice: continue B11.2 Copilot persistence, disabled-state payloads, and safe LLM/document gates. B9.3 delayed/durable worker scheduling and B9.4 macro attachment depth remain named Phase 5 follow-ups. | Step | Required result | Reference source | Verification | | --- | --- | --- | --- | @@ -175,7 +176,8 @@ Next implementation slice: continue B11.1c Captain documents, assistant response | N16 | Keep B10.4 account inbox limits as current limit baseline. | Chatwoot account `usage_limits[:inboxes]` and request exception handler. | Done by `feat(inboxes): enforce chatwoot inbox limits`; generic and dedicated channel create paths return the exact over-limit `402` response and avoid orphan rows. | | N17 | Keep B11.1a Captain assistant resources as current Captain baseline. | `reference/chatwoot/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb`, Captain inboxes controller, assistant Jbuilder views, dashboard Captain assistant/inbox API clients, `config/agents/tools.yml`. | Done by `feat(captain): align assistant resources`; assistant CRUD/tools/inbox binding payloads are account-scoped and frontend-shaped. | | 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 | Update this tracker after every implementation checkpoint. | This document. | `git diff --check`; `go test ./...` for Go changes. | +| 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 | Update this tracker after every implementation checkpoint. | This document. | `git diff --check`; `go test ./...` for Go changes. | Current B2 profile checkpoint: @@ -404,7 +406,7 @@ Upcoming enterprise task boards: | B10 | B10.2 | Add audit writer coverage for representative core and enterprise mutations: inbox, conversation assignment/status, SLA policy, capacity policy, custom role, automation, macro, CSAT review notes. | Chatwoot audit hooks and current Go service mutation points. | Mutation tests assert audit rows with actor, auditable type/id, account, IP/request metadata where available. | Done by `feat(audit): record enterprise mutations` and `feat(audit): cover operational mutations` | | B10 | B10.3 | Align CustomRole permission keys, account-user role resolution, deletion nullification, and authorization failure payloads. | Chatwoot custom role controllers/policies and permission constants. | Permission matrix tests for admin/non-admin/custom-role access. | Done by `feat(custom-roles): align chatwoot permissions` | | 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. | Doing; assistant CRUD/tools/inbox binding and scenario/custom-tool payloads are landed | +| 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. | Todo | | B11 | B11.3 | Add document sync/embedding/LLM job boundaries where external dependencies are required. | Captain/Copilot jobs and document services. | Worker tests or explicit feature-gated fallback tests. | Todo | | 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 | @@ -765,7 +767,7 @@ B11 Captain/Copilot breakdown: | --- | --- | --- | --- | --- | | B11.1a | Align Captain assistant CRUD, tools, and inbox binding/list/delete payloads used by `assistant.js` and `inboxes.js`. | Assistant controller, inboxes controller, assistant Jbuilder views, `config/agents/tools.yml`, dashboard Captain assistant/inbox clients. | Handler tests for assistant lifecycle, account scoping, tools array, raw inbox binding, list meta, and `204` delete. | Done by `feat(captain): align assistant resources` | | 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. | Todo | +| 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 | Implement document sync/indexing gates for Meilisearch or the chosen embedding/search backend without blocking the frontend when LLM config is absent. | Captain document and embedding services. | Tests cover disabled state, failed sync observability, and successful fake backend indexing. | Todo | | B11.3 | Align Copilot thread/message/task APIs, tool calls, preferences, and streaming fallback. | Copilot controllers/services/frontend clients. | Handler tests cover thread/message/task lifecycle, tool-call persistence, disabled-state payloads, and non-streaming fallback. | Todo | @@ -785,7 +787,16 @@ B11.1b current checkpoint: - Scenario create/update now accept nested `{ scenario: ... }`, show/update/delete are scoped by account plus assistant, index returns enabled scenarios only with `{ payload, meta }`, and single-resource mutations return the raw scenario serializer with assistant ID/name. - Custom-tool create/update now accept nested `{ custom_tool: ... }`; create auto-generates the Chatwoot-style slug from title when the frontend omits `slug`; list returns `{ payload, meta }`; show/update/delete are account-scoped; single-resource mutations return the raw custom-tool serializer with Unix timestamps and JSON `auth_config`/`param_schema` values. - Focused tests cover nested frontend bodies, account-scope isolation, disabled scenario filtering, custom-tool slug generation, and absence of local `{ success, data }` envelopes. -- Remaining B11.1c gaps: Captain documents, assistant responses, bulk actions, and `custom_tools#test` still need the current reference payload and safe external-call contract. +- B11.1c has now taken over the remaining Captain resource gaps; Copilot persistence and external document/LLM gates remain in B11.2-B11.3. + +B11.1c current checkpoint: + +- Reference files inspected for this slice: enterprise Captain `documents_controller.rb`, `assistant_responses_controller.rb`, `bulk_actions_controller.rb`, `custom_tools_controller#test`, document/assistant-response/bulk Jbuilder views, and dashboard `document.js`, `response.js`, `bulkActions.js`, and `customTools.js` clients. +- Document create accepts nested `{ document: ... }` with `assistant_id`, validates account/assistant scope, and returns the raw document serializer. Document list returns `{ payload, meta }` with filters for assistant, source, sync state, search, sort, and pagination. Show/delete/sync are account-scoped, delete returns `204`, and sync returns `202 Accepted` while marking `sync_status` as frontend-visible `syncing` through a no-network boundary. +- Assistant responses accept nested `{ assistant_response: ... }`, return raw response serializers with assistant payloads, support list filters for assistant/document/status/search, mark `edited` when question or answer changes, scope all mutations to account, and delete with `204`. +- Bulk actions accept Chatwoot `{ type, ids, fields: { status } }`: `AssistantResponse` supports `approve` and `delete`; `AssistantDocument` supports `sync` and `delete`; invalid requests return `422 { success: false }`. +- Custom-tool `test` now accepts an unsaved nested `{ custom_tool: ... }` config and returns raw `{ status, body }`. The HTTP call boundary is injectable so default tests never open sockets or hit external networks. +- Focused tests cover document list/create/show/sync/delete, response list/create/show/update/delete, bulk resource actions, invalid bulk errors, custom-tool test payloads, account-scope isolation, sync status mapping, and no local `{ success, data }` envelopes. B11 landing rules: @@ -1185,7 +1196,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, and inbox bindings. Assistant CRUD/tools/inbox binding plus scenario/custom-tool CRUD payloads are landed; documents, responses, bulk actions, custom-tool test, and Copilot remain active. +- [ ] Captain/Copilot assistant, custom tools, scenarios, documents, responses, and inbox bindings. Captain assistant CRUD/tools/inbox binding, scenario/custom-tool CRUD, documents, assistant responses, bulk actions, and custom-tool test payloads are landed; Copilot and external document/LLM gates 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. @@ -1204,7 +1215,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` | Assistant CRUD/tools/inbox binding and scenario/custom-tool CRUD payloads are aligned; complete Captain documents/responses/bulk actions/custom-tool test, Copilot threads/messages/tasks, 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 are aligned; complete Copilot threads/messages/tasks, 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 | @@ -1221,7 +1232,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`; route fixtures, feature gates for external LLM dependencies, and frontend smoke screens remain for deeper Captain/Copilot paths. | Doing | +| 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`; route fixtures, feature gates for external LLM dependencies, Copilot, and frontend smoke screens remain. | Doing | Enterprise acceptance gates: @@ -1458,3 +1469,4 @@ Verification milestone gates: - 2026-06-05: B10.4 InboxLimit checkpoint prepared as `feat(inboxes): enforce chatwoot inbox limits`; account-level `inbox_limit` now gates generic inbox and dedicated channel creation with Chatwoot's exact `402` error body, service and handler tests cover unlimited, below-limit, over-limit, and no-orphan LINE channel behavior, and B10 moves to Review. Focused inbox/channel/limit tests, handler/service package tests, escalated full `go test ./...`, and `git diff --check` passed. Next slice is B11 Captain/Copilot. - 2026-06-05: B11.1a Captain assistant checkpoint prepared as `feat(captain): align assistant resources`; assistant CRUD now returns Chatwoot raw assistant payloads and list meta, nested assistant request bodies are accepted, account-scoped get/update/delete prevents cross-account access, tools returns the raw built-in tool array, and assistant inbox create/list/delete use raw inbox/list/204 contracts. Focused CaptainAssistant handler/service tests passed, route dump/parity regenerated with unchanged `TOTAL: 830`, and `git diff --check` passed. Next slice is B11.1b Captain documents/scenarios/responses/custom tools. - 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. diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 2d3feb75..8f9fc6ad 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -560,7 +560,7 @@ func Bootstrap(env string) (*App, error) { // Captain services (P10 M10 — Captain AI + Copilot) captainAssistantService := service.NewCaptainAssistantService(captainAssistantRepo, captainInboxRepo, captainDocumentRepo, captainAssistantResponseRepo, llmProvider) - captainDocumentService := service.NewCaptainDocumentService(captainDocumentRepo, llmProvider) + captainDocumentService := service.NewCaptainDocumentService(captainDocumentRepo, llmProvider, captainAssistantRepo) captainScenarioService := service.NewCaptainScenarioService(captainScenarioRepo, captainAssistantRepo) captainCustomToolService := service.NewCaptainCustomToolService(captainCustomToolRepo) copilotService := service.NewCopilotService(copilotThreadRepo, copilotMessageRepo, copilotSuggestionRepo, llmProvider) @@ -571,6 +571,7 @@ func Bootstrap(env string) (*App, error) { 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) + captainBulkActionService.SetCaptainResourceRepos(captainAssistantResponseRepo, captainDocumentRepo) // Analytics services (P11 — Reports/Analytics) analyticsService := service.NewAnalyticsService(reportingEventRepo, reportingEventsRollupRepo) diff --git a/internal/handler/api/v1/captain_assistant_response_handler.go b/internal/handler/api/v1/captain_assistant_response_handler.go index b6518843..bbc6d2f8 100644 --- a/internal/handler/api/v1/captain_assistant_response_handler.go +++ b/internal/handler/api/v1/captain_assistant_response_handler.go @@ -2,9 +2,9 @@ package v1 import ( "net/http" - "strconv" "github.com/gin-gonic/gin" + "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" @@ -23,8 +23,8 @@ func NewCaptainAssistantResponseHandler(svc *service.CaptainAssistantResponseSer // ProcessResponse generates and optionally stores an assistant response. // POST /api/v1/accounts/:id/captain/assistant_responses func (h *CaptainAssistantResponseHandler) ProcessResponse(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 +35,7 @@ func (h *CaptainAssistantResponseHandler) ProcessResponse(c *gin.Context) { return } - result, err := h.svc.ProcessResponse(c.Request.Context(), uint(accountID), &req) + result, err := h.svc.ProcessResponse(c.Request.Context(), accountID, &req) if err != nil { applogger.L().Errorf("Process assistant response: %v", err) response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to process assistant response") @@ -49,60 +49,64 @@ func (h *CaptainAssistantResponseHandler) ProcessResponse(c *gin.Context) { // GET /api/v1/accounts/:id/captain/assistant_responses // Reference: Chatwoot Captain::AssistantResponsesController#index func (h *CaptainAssistantResponseHandler) List(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 } - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("per_page", "25")) - assistantID, _ := strconv.ParseUint(c.Query("assistant_id"), 10, 64) - documentID, _ := strconv.ParseUint(c.Query("document_id"), 10, 64) + page, _ := parseIntQueryDefault(c, "page", 1) + pageSize := 25 + assistantID, _ := parseOptionalUintQueryParam(c, "assistant_id") + documentID, _ := parseOptionalUintQueryParam(c, "document_id") status := c.Query("status") search := c.Query("search") - responses, total, err := h.svc.List(c.Request.Context(), uint(accountID), uint(assistantID), uint(documentID), status, search, page, pageSize) + responses, total, err := h.svc.List(c.Request.Context(), accountID, assistantID, documentID, status, search, page, pageSize) if err != nil { applogger.L().Errorf("List assistant responses: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list responses") return } - response.OKWithMeta(c, responses, page, pageSize, total) + payload := make([]gin.H, 0, len(responses)) + for i := range responses { + payload = append(payload, captainAssistantResponsePayload(&responses[i])) + } + c.JSON(http.StatusOK, gin.H{"payload": payload, "meta": gin.H{"total_count": total, "page": page}}) } // Get returns a single assistant response. // GET /api/v1/accounts/:id/captain/assistant_responses/:response_id // Reference: Chatwoot Captain::AssistantResponsesController#show func (h *CaptainAssistantResponseHandler) 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 } - responseID, err := strconv.ParseUint(c.Param("response_id"), 10, 64) + responseID, err := parseUintParam(c, "response_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid response_id") return } - resp, err := h.svc.Get(c.Request.Context(), uint(accountID), uint(responseID)) + resp, err := h.svc.Get(c.Request.Context(), accountID, responseID) if err != nil { applogger.L().Errorf("Get assistant response: %v", err) response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "response not found") return } - response.OK(c, resp) + c.JSON(http.StatusOK, captainAssistantResponsePayload(resp)) } // Update modifies an assistant response. // PUT /api/v1/accounts/:id/captain/assistant_responses/:response_id // Reference: Chatwoot Captain::AssistantResponsesController#update func (h *CaptainAssistantResponseHandler) 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 } - responseID, err := strconv.ParseUint(c.Param("response_id"), 10, 64) + responseID, err := parseUintParam(c, "response_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid response_id") return @@ -112,34 +116,34 @@ func (h *CaptainAssistantResponseHandler) Update(c *gin.Context) { Answer string `json:"answer"` Status string `json:"status"` } - if err := c.ShouldBindJSON(&req); err != nil { + if err := bindNestedJSONPayload(c, "assistant_response", &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } - resp, err := h.svc.Update(c.Request.Context(), uint(accountID), uint(responseID), req.Question, req.Answer, req.Status) + resp, err := h.svc.Update(c.Request.Context(), accountID, responseID, req.Question, req.Answer, req.Status) if err != nil { applogger.L().Errorf("Update assistant response: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update response") return } - response.OK(c, resp) + c.JSON(http.StatusOK, captainAssistantResponsePayload(resp)) } // Delete removes an assistant response. // DELETE /api/v1/accounts/:id/captain/assistant_responses/:response_id // Reference: Chatwoot Captain::AssistantResponsesController#destroy (head :no_content) func (h *CaptainAssistantResponseHandler) 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 } - responseID, err := strconv.ParseUint(c.Param("response_id"), 10, 64) + responseID, err := parseUintParam(c, "response_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid response_id") return } - if err := h.svc.Delete(c.Request.Context(), uint(accountID), uint(responseID)); err != nil { + if err := h.svc.Delete(c.Request.Context(), accountID, responseID); err != nil { applogger.L().Errorf("Delete assistant response: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete response") return @@ -152,16 +156,12 @@ func (h *CaptainAssistantResponseHandler) Delete(c *gin.Context) { // POST /api/v1/accounts/:id/captain/assistant_responses // Reference: Chatwoot Captain::AssistantResponsesController#create func (h *CaptainAssistantResponseHandler) 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 } userID := getUserID(c) - if userID == 0 { - response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "user not authenticated") - return - } var req struct { Question string `json:"question" validate:"required"` @@ -169,16 +169,49 @@ func (h *CaptainAssistantResponseHandler) Create(c *gin.Context) { AssistantID uint `json:"assistant_id" validate:"required"` Status string `json:"status"` } - if err := c.ShouldBindJSON(&req); err != nil { + if err := bindNestedJSONPayload(c, "assistant_response", &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } - resp, err := h.svc.Create(c.Request.Context(), uint(accountID), userID, req.AssistantID, req.Question, req.Answer, req.Status) + resp, err := h.svc.Create(c.Request.Context(), accountID, userID, req.AssistantID, req.Question, req.Answer, req.Status) if err != nil { applogger.L().Errorf("Create assistant response: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create response") return } - response.OK(c, resp) + c.JSON(http.StatusOK, captainAssistantResponsePayload(resp)) +} + +func captainAssistantResponsePayload(resp *model.CaptainAssistantResponse) gin.H { + payload := gin.H{ + "account_id": resp.AccountID, + "answer": resp.Answer, + "assistant": captainResponseAssistantPayload(resp), + "created_at": resp.CreatedAt.Unix(), + "id": resp.ID, + "question": resp.Question, + "updated_at": resp.UpdatedAt.Unix(), + "status": resp.Status, + "edited": resp.Edited, + } + if resp.DocumentableID != nil { + payload["documentable"] = captainResponseDocumentablePayload(resp) + } + return payload +} + +func captainResponseAssistantPayload(resp *model.CaptainAssistantResponse) gin.H { + if resp.Assistant.ID == 0 { + return gin.H{"id": resp.AssistantID} + } + return captainAssistantPayload(&resp.Assistant) +} + +func captainResponseDocumentablePayload(resp *model.CaptainAssistantResponse) gin.H { + payload := gin.H{"type": resp.DocumentableType, "id": *resp.DocumentableID} + if resp.DocumentableType == "Conversation" { + payload["display_id"] = *resp.DocumentableID + } + return payload } diff --git a/internal/handler/api/v1/captain_bulk_action_handler.go b/internal/handler/api/v1/captain_bulk_action_handler.go index 87dd94fa..86415776 100644 --- a/internal/handler/api/v1/captain_bulk_action_handler.go +++ b/internal/handler/api/v1/captain_bulk_action_handler.go @@ -1,8 +1,8 @@ package v1 import ( + "encoding/json" "net/http" - "strconv" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/service" @@ -23,14 +23,26 @@ func NewCaptainBulkActionHandler(svc *service.CaptainBulkActionService) *Captain // Execute performs a bulk AI action on multiple conversations. // POST /api/v1/accounts/:id/captain/bulk_actions func (h *CaptainBulkActionHandler) Execute(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 } + body, err := c.GetRawData() + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) + return + } + + var chatwootReq service.ChatwootBulkActionRequest + if err := json.Unmarshal(body, &chatwootReq); err == nil && chatwootReq.Type != "" { + h.executeChatwoot(c, accountID, &chatwootReq) + return + } + var req service.BulkActionRequest - if err := c.ShouldBindJSON(&req); err != nil { + if err := json.Unmarshal(body, &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } @@ -51,7 +63,7 @@ func (h *CaptainBulkActionHandler) Execute(c *gin.Context) { return } - result, err := h.svc.Execute(c.Request.Context(), uint(accountID), &req) + result, err := h.svc.Execute(c.Request.Context(), accountID, &req) if err != nil { applogger.L().Errorf("Bulk action: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to execute bulk action") @@ -60,3 +72,30 @@ func (h *CaptainBulkActionHandler) Execute(c *gin.Context) { response.OK(c, result) } + +func (h *CaptainBulkActionHandler) executeChatwoot(c *gin.Context, accountID uint, req *service.ChatwootBulkActionRequest) { + result, err := h.svc.ExecuteChatwoot(c.Request.Context(), accountID, req) + if err != nil { + applogger.L().Errorf("Chatwoot bulk action: %v", err) + c.JSON(http.StatusUnprocessableEntity, gin.H{"success": false}) + return + } + + if result.Empty { + c.JSON(http.StatusOK, []gin.H{}) + return + } + if result.AssistantResponses != nil { + payload := make([]gin.H, 0, len(result.AssistantResponses)) + for i := range result.AssistantResponses { + payload = append(payload, captainAssistantResponsePayload(&result.AssistantResponses[i])) + } + c.JSON(http.StatusOK, payload) + return + } + if result.IDs != nil { + c.JSON(http.StatusOK, gin.H{"ids": result.IDs, "count": result.Count}) + return + } + c.JSON(http.StatusOK, gin.H{"count": result.Count}) +} diff --git a/internal/handler/api/v1/captain_custom_tool_handler.go b/internal/handler/api/v1/captain_custom_tool_handler.go index 2789f5f6..dce395ca 100644 --- a/internal/handler/api/v1/captain_custom_tool_handler.go +++ b/internal/handler/api/v1/captain_custom_tool_handler.go @@ -189,7 +189,7 @@ func (h *CaptainCustomToolHandler) TestTool(c *gin.Context) { result, err := h.svc.TestTool(c.Request.Context(), accountID, &req) if err != nil { applogger.L().Errorf("TestTool: %v", err) - response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to test tool") + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) return } diff --git a/internal/handler/api/v1/captain_custom_tool_test_handler_test.go b/internal/handler/api/v1/captain_custom_tool_test_handler_test.go index 4af77d51..3ad0d169 100644 --- a/internal/handler/api/v1/captain_custom_tool_test_handler_test.go +++ b/internal/handler/api/v1/captain_custom_tool_test_handler_test.go @@ -54,13 +54,13 @@ func (s *CaptainCustomToolTestHandlerTestSuite) SetupSuite() { s.Require().NoError(db.Create(account).Error) s.account = account - // 创建测试自定义工具(指向 httpbin.org 的 GET 端点,测试时替换为本地mock) + // 创建测试自定义工具 tool := &model.CaptainCustomTool{ AccountID: account.ID, Title: "测试工具", Slug: "test-tool", Description: "用于测试的工具", - EndpointURL: "https://httpbin.org/get", + EndpointURL: "http://tool.test", HTTPMethod: "GET", AuthType: model.ToolAuthTypeNone, Enabled: true, @@ -71,6 +71,9 @@ func (s *CaptainCustomToolTestHandlerTestSuite) SetupSuite() { // 创建 repo + service + handler toolRepo := repository.NewCaptainCustomToolRepo(db) svc := service.NewCaptainCustomToolService(toolRepo) + svc.SetHTTPClient(fakeCaptainToolHTTPDoer(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusCreated, Body: http.NoBody, Header: make(http.Header)}, nil + })) s.handler = NewCaptainCustomToolHandler(svc) // 设置路由 @@ -108,16 +111,11 @@ func (s *CaptainCustomToolTestHandlerTestSuite) TestTestTool_成功测试工具( req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) - // httpbin.org 可能不可达,所以允许成功或失败 - // 关键是接口返回正确格式 - if w.Code == http.StatusOK { - var resp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &resp) - assert.Contains(s.T(), resp, "success") - } else { - // 即使外部请求失败,也应该返回 InternalServerError 格式 - assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code) - } + assert.Equal(s.T(), http.StatusOK, w.Code) + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(s.T(), float64(http.StatusCreated), resp["status"]) + assert.Contains(s.T(), resp, "body") } func (s *CaptainCustomToolTestHandlerTestSuite) TestTestTool_无效accountID() { @@ -162,7 +160,6 @@ func (s *CaptainCustomToolTestHandlerTestSuite) TestTestTool_不存在的工具I req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) - // 不存在的工具ID应返回500 assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code) } @@ -172,7 +169,7 @@ func (s *CaptainCustomToolTestHandlerTestSuite) TestTestTool_带POST方法和参 AccountID: s.account.ID, Title: "POST测试工具", Slug: "post-test-tool", - EndpointURL: "https://httpbin.org/post", + EndpointURL: "http://tool.test", HTTPMethod: "POST", AuthType: model.ToolAuthTypeNone, RequestTemplate: "{\"message\": \"{{.message}}\"}", @@ -193,12 +190,10 @@ func (s *CaptainCustomToolTestHandlerTestSuite) TestTestTool_带POST方法和参 req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) - // httpbin.org 可能不可达,关键检查返回格式 - if w.Code == http.StatusOK { - var resp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &resp) - assert.Contains(s.T(), resp, "success") - } + assert.Equal(s.T(), http.StatusOK, w.Code) + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(s.T(), float64(http.StatusCreated), resp["status"]) } func TestCaptainCustomToolTestHandlerSuite(t *testing.T) { diff --git a/internal/handler/api/v1/captain_document_handler.go b/internal/handler/api/v1/captain_document_handler.go index 0aad4eef..e521bbd6 100644 --- a/internal/handler/api/v1/captain_document_handler.go +++ b/internal/handler/api/v1/captain_document_handler.go @@ -5,9 +5,9 @@ import ( "strconv" "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" - "github.com/gochat/gochat/pkg/pagination" "github.com/gochat/gochat/pkg/response" ) @@ -25,50 +25,58 @@ func NewCaptainDocumentHandler(svc *service.CaptainDocumentService) *CaptainDocu // Create creates a new captain document. // POST /api/v1/accounts/:account_id/captain_assistants/:assistant_id/documents func (h *CaptainDocumentHandler) 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 } - assistantID, err := strconv.ParseUint(c.Param("assistant_id"), 10, 64) - if err != nil { - response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid assistant_id") - return - } var req service.CreateDocumentRequest - if err := c.ShouldBindJSON(&req); err != nil { + if err := bindNestedJSONPayload(c, "document", &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } - - doc, err := h.svc.Create(c.Request.Context(), uint(assistantID), uint(accountID), &req) - if err != nil { - applogger.L().Errorf("Create captain document: %v", err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create document") + assistantID := req.AssistantID + if assistantID == 0 { + assistantID, _ = parseUintParam(c, "assistant_id") + } + if assistantID == 0 { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "Missing Assistant") return } - response.Created(c, doc) + doc, err := h.svc.Create(c.Request.Context(), assistantID, accountID, &req) + if err != nil { + applogger.L().Errorf("Create captain document: %v", err) + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "failed to create document") + return + } + + c.JSON(http.StatusOK, captainDocumentPayload(doc)) } // Get retrieves a captain document by ID. // GET /api/v1/accounts/:account_id/captain_documents/:id func (h *CaptainDocumentHandler) Get(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) + accountID := parseAccountIDParam(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") + return + } + id, err := parseUintAnyParam(c, "document_id", "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } - doc, err := h.svc.Get(c.Request.Context(), uint(id)) + doc, err := h.svc.GetByAccount(c.Request.Context(), accountID, id) if err != nil { applogger.L().Errorf("Get captain document: %v", err) response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "document not found") return } - response.OK(c, doc) + c.JSON(http.StatusOK, captainDocumentPayload(doc)) } // Update updates an existing captain document. @@ -99,15 +107,20 @@ func (h *CaptainDocumentHandler) Update(c *gin.Context) { // Delete deletes a captain document. // DELETE /api/v1/accounts/:account_id/captain_documents/:id func (h *CaptainDocumentHandler) Delete(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) + accountID := parseAccountIDParam(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") + return + } + id, err := parseUintAnyParam(c, "document_id", "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } - if err := h.svc.Delete(c.Request.Context(), uint(id)); err != nil { + if err := h.svc.DeleteByAccount(c.Request.Context(), accountID, id); err != nil { applogger.L().Errorf("Delete captain document: %v", err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete document") + response.AbortWithStatusError(c, captainAssistantErrorStatus(err), response.ErrInternal, "failed to delete document") return } @@ -117,21 +130,36 @@ func (h *CaptainDocumentHandler) Delete(c *gin.Context) { // List retrieves documents for an assistant. // GET /api/v1/accounts/:account_id/captain_assistants/:assistant_id/documents func (h *CaptainDocumentHandler) List(c *gin.Context) { - assistantID, err := strconv.ParseUint(c.Param("assistant_id"), 10, 64) - if err != nil { - response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid assistant_id") + accountID := parseAccountIDParam(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } - - p := pagination.Parse(c) - docs, count, err := h.svc.List(c.Request.Context(), uint(assistantID), p.Offset, p.PerPage) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + assistantID, _ := parseOptionalUintQueryParam(c, "assistant_id") + if assistantID == 0 { + assistantID, _ = parseUintParam(c, "assistant_id") + } + docs, count, currentPage, err := h.svc.ListByAccount(c.Request.Context(), accountID, service.ListDocumentsRequest{ + AssistantID: assistantID, + Page: page, + PerPage: 25, + Filter: c.Query("filter"), + Source: c.Query("source"), + Sort: c.Query("sort"), + SearchKey: c.Query("search_key"), + }) if err != nil { applogger.L().Errorf("List captain documents: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list documents") return } - response.OKWithMeta(c, docs, p.Page, p.PerPage, count) + payload := make([]gin.H, 0, len(docs)) + for i := range docs { + payload = append(payload, captainDocumentPayload(&docs[i])) + } + c.JSON(http.StatusOK, gin.H{"payload": payload, "meta": gin.H{"total_count": count, "page": currentPage}}) } // ProcessDocument triggers document content extraction and embedding generation. @@ -155,17 +183,68 @@ func (h *CaptainDocumentHandler) ProcessDocument(c *gin.Context) { // SyncDocument triggers re-fetching content from the external URL. // POST /api/v1/accounts/:account_id/captain_documents/:id/sync func (h *CaptainDocumentHandler) SyncDocument(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) + accountID := parseAccountIDParam(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") + return + } + id, err := parseUintAnyParam(c, "document_id", "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } - if err := h.svc.SyncDocument(c.Request.Context(), uint(id)); err != nil { + if _, err := h.svc.MarkSyncing(c.Request.Context(), accountID, id); err != nil { applogger.L().Errorf("SyncDocument: %v", err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to sync document") + response.AbortWithStatusError(c, captainAssistantErrorStatus(err), response.ErrInternal, "failed to sync document") return } - response.OK(c, gin.H{"synced": true}) -} \ No newline at end of file + c.Status(http.StatusAccepted) +} + +func captainDocumentPayload(doc *model.CaptainDocument) gin.H { + status := doc.Status + if status == "" { + status = model.DocumentStatusPending + } + syncStatus := doc.SyncStatus + if syncStatus == model.DocumentSyncStatusPending { + syncStatus = "syncing" + } + payload := gin.H{ + "account_id": doc.AccountID, + "assistant": captainDocumentAssistantPayload(doc), + "content": doc.Content, + "content_type": "text/html", + "created_at": doc.CreatedAt.Unix(), + "external_link": doc.ExternalLink, + "display_url": doc.ExternalLink, + "file_size": 0, + "pdf_document": false, + "id": doc.ID, + "name": doc.Name, + "status": status, + "sync_status": syncStatus, + "sync_in_progress": syncStatus == "syncing" || syncStatus == model.DocumentSyncStatusPending, + "last_synced_at": int64PointerValue(doc.LastSyncedAt), + "last_sync_attempted_at": int64PointerValue(doc.LastSyncAttemptedAt), + "last_sync_error_code": doc.LastSyncErrorCode, + "updated_at": doc.UpdatedAt.Unix(), + } + return payload +} + +func captainDocumentAssistantPayload(doc *model.CaptainDocument) gin.H { + if doc.Assistant.ID == 0 { + return gin.H{"id": doc.AssistantID} + } + return captainAssistantPayload(&doc.Assistant) +} + +func int64PointerValue(value *int64) any { + if value == nil { + return nil + } + return *value +} diff --git a/internal/handler/api/v1/captain_resource_parity_handler_test.go b/internal/handler/api/v1/captain_resource_parity_handler_test.go index f14b4f8c..b412178b 100644 --- a/internal/handler/api/v1/captain_resource_parity_handler_test.go +++ b/internal/handler/api/v1/captain_resource_parity_handler_test.go @@ -4,9 +4,11 @@ import ( "bytes" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "strconv" + "strings" "testing" "github.com/gin-gonic/gin" @@ -19,6 +21,12 @@ import ( "gorm.io/gorm" ) +type fakeCaptainToolHTTPDoer func(*http.Request) (*http.Response, error) + +func (f fakeCaptainToolHTTPDoer) Do(req *http.Request) (*http.Response, error) { + return f(req) +} + func setupCaptainResourceParityTest(t *testing.T) (*gin.Engine, *gorm.DB, *model.Account, *model.Account, *model.CaptainAssistant) { t.Helper() gin.SetMode(gin.TestMode) @@ -28,6 +36,8 @@ func setupCaptainResourceParityTest(t *testing.T) (*gin.Engine, *gorm.DB, *model require.NoError(t, db.AutoMigrate( &model.Account{}, &model.CaptainAssistant{}, + &model.CaptainDocument{}, + &model.CaptainAssistantResponse{}, &model.CaptainScenario{}, &model.CaptainCustomTool{}, )) @@ -50,7 +60,19 @@ func setupCaptainResourceParityTest(t *testing.T) (*gin.Engine, *gorm.DB, *model toolRepo := repository.NewCaptainCustomToolRepo(db) toolSvc := service.NewCaptainCustomToolService(toolRepo) + toolSvc.SetHTTPClient(fakeCaptainToolHTTPDoer(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusCreated, Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), Header: make(http.Header)}, nil + })) toolHandler := NewCaptainCustomToolHandler(toolSvc) + documentRepo := repository.NewCaptainDocumentRepo(db) + documentSvc := service.NewCaptainDocumentService(documentRepo, nil, assistantRepo) + documentHandler := NewCaptainDocumentHandler(documentSvc) + responseRepo := repository.NewCaptainAssistantResponseRepo(db) + responseSvc := service.NewCaptainAssistantResponseService(assistantRepo, responseRepo, nil, nil, nil, nil) + responseHandler := NewCaptainAssistantResponseHandler(responseSvc) + bulkSvc := service.NewCaptainBulkActionService(nil, nil, assistantRepo, nil, nil, nil, responseSvc) + bulkSvc.SetCaptainResourceRepos(responseRepo, documentRepo) + bulkHandler := NewCaptainBulkActionHandler(bulkSvc) router := gin.New() accountGroup := router.Group("/api/v1/accounts/:account_id/captain") @@ -62,12 +84,30 @@ func setupCaptainResourceParityTest(t *testing.T) (*gin.Engine, *gorm.DB, *model assistantScenarios.DELETE("/:scenario_id", scenarioHandler.Delete) customTools := accountGroup.Group("/custom_tools") + customTools.POST("/test", toolHandler.TestTool) customTools.GET("/", toolHandler.List) customTools.POST("/", toolHandler.Create) customTools.GET("/:tool_id", toolHandler.Get) customTools.PUT("/:tool_id", toolHandler.Update) customTools.DELETE("/:tool_id", toolHandler.Delete) + documents := accountGroup.Group("/documents") + documents.GET("/", documentHandler.List) + documents.POST("/", documentHandler.Create) + documents.GET("/:document_id", documentHandler.Get) + documents.DELETE("/:document_id", documentHandler.Delete) + documents.POST("/:document_id/sync", documentHandler.SyncDocument) + + assistantResponses := accountGroup.Group("/assistant_responses") + assistantResponses.GET("/", responseHandler.List) + assistantResponses.POST("/", responseHandler.Create) + assistantResponses.GET("/:response_id", responseHandler.Get) + assistantResponses.PUT("/:response_id", responseHandler.Update) + assistantResponses.DELETE("/:response_id", responseHandler.Delete) + + bulkActions := accountGroup.Group("/bulk_actions") + bulkActions.POST("/", bulkHandler.Execute) + return router, db, account, otherAccount, assistant } @@ -179,3 +219,149 @@ func TestCaptainCustomToolHandler_ChatwootToolPayloadsAndScope(t *testing.T) { w = captainResourceJSONRequest(t, router, http.MethodDelete, fmt.Sprintf("%s/%d", basePath, toolID), nil) assert.Equal(t, http.StatusNoContent, w.Code) } + +func TestCaptainDocumentHandler_ChatwootDocumentPayloadsAndSync(t *testing.T) { + router, _, account, otherAccount, assistant := setupCaptainResourceParityTest(t) + basePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/captain/documents" + otherBasePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(otherAccount.ID), 10) + "/captain/documents" + + body := map[string]any{"document": map[string]any{ + "name": "Help center", + "external_link": "https://example.com/help", + "assistant_id": assistant.ID, + }} + w := captainResourceJSONRequest(t, router, http.MethodPost, basePath+"/", body) + assert.Equal(t, http.StatusOK, w.Code) + var created map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created)) + assert.NotContains(t, created, "success") + assert.Equal(t, "Help center", created["name"]) + assert.Equal(t, float64(account.ID), created["account_id"]) + assert.Equal(t, "Fin", created["assistant"].(map[string]any)["name"]) + documentID := uint(created["id"].(float64)) + + w = captainResourceJSONRequest(t, router, http.MethodGet, basePath+"/?assistant_id="+strconv.FormatUint(uint64(assistant.ID), 10), nil) + assert.Equal(t, http.StatusOK, w.Code) + var listResp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &listResp)) + assert.Len(t, listResp["payload"], 1) + assert.Equal(t, float64(1), listResp["meta"].(map[string]any)["total_count"]) + + w = captainResourceJSONRequest(t, router, http.MethodGet, fmt.Sprintf("%s/%d", otherBasePath, documentID), nil) + assert.Equal(t, http.StatusNotFound, w.Code) + + w = captainResourceJSONRequest(t, router, http.MethodPost, fmt.Sprintf("%s/%d/sync", basePath, documentID), nil) + assert.Equal(t, http.StatusAccepted, w.Code) + + w = captainResourceJSONRequest(t, router, http.MethodGet, fmt.Sprintf("%s/%d", basePath, documentID), nil) + assert.Equal(t, http.StatusOK, w.Code) + var synced map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &synced)) + assert.Equal(t, "syncing", synced["sync_status"]) + assert.Equal(t, true, synced["sync_in_progress"]) + + w = captainResourceJSONRequest(t, router, http.MethodDelete, fmt.Sprintf("%s/%d", basePath, documentID), nil) + assert.Equal(t, http.StatusNoContent, w.Code) +} + +func TestCaptainAssistantResponseHandler_ChatwootResponsePayloadsAndFilters(t *testing.T) { + router, _, account, otherAccount, assistant := setupCaptainResourceParityTest(t) + basePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/captain/assistant_responses" + otherBasePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(otherAccount.ID), 10) + "/captain/assistant_responses" + + body := map[string]any{"assistant_response": map[string]any{ + "question": "Where is my order?", + "answer": "It ships today.", + "assistant_id": assistant.ID, + "status": "pending", + }} + w := captainResourceJSONRequest(t, router, http.MethodPost, basePath+"/", body) + assert.Equal(t, http.StatusOK, w.Code) + var created map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created)) + assert.NotContains(t, created, "success") + assert.Equal(t, "pending", created["status"]) + assert.Equal(t, "Fin", created["assistant"].(map[string]any)["name"]) + responseID := uint(created["id"].(float64)) + + w = captainResourceJSONRequest(t, router, http.MethodGet, basePath+"/?status=pending&search=order", nil) + assert.Equal(t, http.StatusOK, w.Code) + var listResp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &listResp)) + assert.Len(t, listResp["payload"], 1) + assert.Equal(t, float64(1), listResp["meta"].(map[string]any)["total_count"]) + + w = captainResourceJSONRequest(t, router, http.MethodGet, fmt.Sprintf("%s/%d", otherBasePath, responseID), nil) + assert.Equal(t, http.StatusNotFound, w.Code) + + updateBody := map[string]any{"assistant_response": map[string]any{"answer": "It shipped.", "status": "approved"}} + w = captainResourceJSONRequest(t, router, http.MethodPut, fmt.Sprintf("%s/%d", basePath, responseID), updateBody) + assert.Equal(t, http.StatusOK, w.Code) + var updated map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &updated)) + assert.Equal(t, "approved", updated["status"]) + assert.Equal(t, true, updated["edited"]) + + w = captainResourceJSONRequest(t, router, http.MethodDelete, fmt.Sprintf("%s/%d", basePath, responseID), nil) + assert.Equal(t, http.StatusNoContent, w.Code) +} + +func TestCaptainBulkActionHandler_ChatwootResourceActions(t *testing.T) { + router, db, account, _, assistant := setupCaptainResourceParityTest(t) + basePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/captain/bulk_actions" + + resp := &model.CaptainAssistantResponse{AccountID: account.ID, AssistantID: assistant.ID, Question: "Q", Answer: "A", Status: model.ResponseStatusPending} + require.NoError(t, db.Create(resp).Error) + w := captainResourceJSONRequest(t, router, http.MethodPost, basePath+"/", map[string]any{ + "type": "AssistantResponse", + "ids": []uint{resp.ID}, + "fields": map[string]any{ + "status": "approve", + }, + }) + assert.Equal(t, http.StatusOK, w.Code) + var approved []map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &approved)) + require.Len(t, approved, 1) + assert.Equal(t, "approved", approved[0]["status"]) + + doc := &model.CaptainDocument{AccountID: account.ID, AssistantID: assistant.ID, Name: "Doc", ExternalLink: "https://example.com", Status: model.DocumentStatusCompleted, SyncStatus: model.DocumentSyncStatusSynced} + require.NoError(t, db.Create(doc).Error) + w = captainResourceJSONRequest(t, router, http.MethodPost, basePath+"/", map[string]any{ + "type": "AssistantDocument", + "ids": []uint{doc.ID}, + "fields": map[string]any{ + "status": "sync", + }, + }) + assert.Equal(t, http.StatusOK, w.Code) + var syncResp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &syncResp)) + assert.Equal(t, float64(1), syncResp["count"]) + + w = captainResourceJSONRequest(t, router, http.MethodPost, basePath+"/", map[string]any{"type": "Unknown", "ids": []uint{1}, "fields": map[string]any{"status": "delete"}}) + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) + var invalid map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &invalid)) + assert.Equal(t, false, invalid["success"]) +} + +func TestCaptainCustomToolHandler_ChatwootTestToolPayload(t *testing.T) { + router, _, account, _, _ := setupCaptainResourceParityTest(t) + + basePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/captain/custom_tools/test" + w := captainResourceJSONRequest(t, router, http.MethodPost, basePath, map[string]any{"custom_tool": map[string]any{ + "title": "Tester", + "endpoint_url": "http://tool.test", + "http_method": "POST", + "auth_type": "none", + "request_template": `{"value":"{{.value}}"}`, + "params": map[string]any{"value": "ping"}, + }}) + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, float64(http.StatusCreated), resp["status"]) + assert.Equal(t, `{"ok":true}`, resp["body"]) + assert.NotContains(t, resp, "success") +} diff --git a/internal/handler/api/v1/helpers.go b/internal/handler/api/v1/helpers.go index cef910a0..659db746 100644 --- a/internal/handler/api/v1/helpers.go +++ b/internal/handler/api/v1/helpers.go @@ -16,6 +16,30 @@ func parseUintParam(c *gin.Context, param string) (uint, error) { return uint(n), nil } +func parseOptionalUintQueryParam(c *gin.Context, param string) (uint, error) { + val := c.Query(param) + if val == "" { + return 0, nil + } + n, err := strconv.ParseUint(val, 10, 32) + if err != nil { + return 0, err + } + return uint(n), nil +} + +func parseIntQueryDefault(c *gin.Context, param string, fallback int) (int, error) { + val := c.Query(param) + if val == "" { + return fallback, nil + } + n, err := strconv.Atoi(val) + if err != nil { + return fallback, err + } + return n, nil +} + func parseUintAnyParam(c *gin.Context, params ...string) (uint, error) { var lastErr error for _, param := range params { diff --git a/internal/model/captain_models.go b/internal/model/captain_models.go index c2f854dd..46b2d4f5 100644 --- a/internal/model/captain_models.go +++ b/internal/model/captain_models.go @@ -184,6 +184,7 @@ type CaptainDocument struct { Metadata json.RawMessage `gorm:"type:jsonb;serializer:json" json:"metadata,omitempty"` // Relationships + Assistant CaptainAssistant `gorm:"foreignKey:AssistantID" json:"assistant,omitempty"` Responses []CaptainAssistantResponse `gorm:"foreignKey:DocumentableID" json:"responses,omitempty"` } @@ -210,6 +211,8 @@ type CaptainAssistantResponse struct { Edited bool `gorm:"default:false;not null" json:"edited"` // pgvector-go Vector type for 1536-dimensional embeddings Embedding pgvector.Vector `gorm:"type:vector(1536)" json:"embedding,omitempty"` + + Assistant CaptainAssistant `gorm:"foreignKey:AssistantID" json:"assistant,omitempty"` } func (CaptainAssistantResponse) TableName() string { return "captain_assistant_responses" } diff --git a/internal/repository/captain_assistant_response_repo.go b/internal/repository/captain_assistant_response_repo.go index ea5946c6..7a3cb76b 100644 --- a/internal/repository/captain_assistant_response_repo.go +++ b/internal/repository/captain_assistant_response_repo.go @@ -27,7 +27,15 @@ func (r *CaptainAssistantResponseRepo) Create(ctx context.Context, resp *model.C func (r *CaptainAssistantResponseRepo) GetByID(ctx context.Context, id uint) (*model.CaptainAssistantResponse, error) { var resp model.CaptainAssistantResponse - if err := r.db.WithContext(ctx).First(&resp, id).Error; err != nil { + if err := r.db.WithContext(ctx).Preload("Assistant").First(&resp, id).Error; err != nil { + return nil, err + } + return &resp, nil +} + +func (r *CaptainAssistantResponseRepo) GetByAccountAndID(ctx context.Context, accountID, id uint) (*model.CaptainAssistantResponse, error) { + var resp model.CaptainAssistantResponse + if err := r.db.WithContext(ctx).Preload("Assistant").Where("account_id = ? AND id = ?", accountID, id).First(&resp).Error; err != nil { return nil, err } return &resp, nil @@ -41,6 +49,10 @@ func (r *CaptainAssistantResponseRepo) Delete(ctx context.Context, id uint) erro return r.db.WithContext(ctx).Delete(&model.CaptainAssistantResponse{}, id).Error } +func (r *CaptainAssistantResponseRepo) DeleteByAccount(ctx context.Context, accountID, id uint) error { + return r.db.WithContext(ctx).Where("account_id = ? AND id = ?", accountID, id).Delete(&model.CaptainAssistantResponse{}).Error +} + func (r *CaptainAssistantResponseRepo) ListByAssistant(ctx context.Context, assistantID uint, offset, limit int) ([]model.CaptainAssistantResponse, int64, error) { var responses []model.CaptainAssistantResponse var count int64 @@ -82,4 +94,4 @@ func (r *CaptainAssistantResponseRepo) SimilaritySearch(ctx context.Context, ass // SearchByEmbedding is an alias for SimilaritySearch for backward compatibility. func (r *CaptainAssistantResponseRepo) SearchByEmbedding(ctx context.Context, assistantID uint, embedding pgvector.Vector, limit int) ([]model.CaptainAssistantResponse, error) { return r.SimilaritySearch(ctx, assistantID, embedding, limit) -} \ No newline at end of file +} diff --git a/internal/repository/captain_document_repo.go b/internal/repository/captain_document_repo.go index 77afbeb0..c962553b 100644 --- a/internal/repository/captain_document_repo.go +++ b/internal/repository/captain_document_repo.go @@ -2,6 +2,7 @@ package repository import ( "context" + "strings" "github.com/gochat/gochat/internal/model" "gorm.io/gorm" @@ -16,13 +17,25 @@ func NewCaptainDocumentRepo(db *gorm.DB) *CaptainDocumentRepo { return &CaptainDocumentRepo{db: db} } +func (r *CaptainDocumentRepo) DB() *gorm.DB { + return r.db +} + func (r *CaptainDocumentRepo) Create(ctx context.Context, doc *model.CaptainDocument) error { return r.db.WithContext(ctx).Create(doc).Error } func (r *CaptainDocumentRepo) GetByID(ctx context.Context, id uint) (*model.CaptainDocument, error) { var doc model.CaptainDocument - if err := r.db.WithContext(ctx).First(&doc, id).Error; err != nil { + if err := r.db.WithContext(ctx).Preload("Assistant").First(&doc, id).Error; err != nil { + return nil, err + } + return &doc, nil +} + +func (r *CaptainDocumentRepo) GetByAccountAndID(ctx context.Context, accountID, id uint) (*model.CaptainDocument, error) { + var doc model.CaptainDocument + if err := r.db.WithContext(ctx).Preload("Assistant").Where("account_id = ? AND id = ?", accountID, id).First(&doc).Error; err != nil { return nil, err } return &doc, nil @@ -36,12 +49,67 @@ func (r *CaptainDocumentRepo) Delete(ctx context.Context, id uint) error { return r.db.WithContext(ctx).Delete(&model.CaptainDocument{}, id).Error } +func (r *CaptainDocumentRepo) DeleteByAccount(ctx context.Context, accountID, id uint) error { + return r.db.WithContext(ctx).Where("account_id = ? AND id = ?", accountID, id).Delete(&model.CaptainDocument{}).Error +} + func (r *CaptainDocumentRepo) ListByAssistant(ctx context.Context, assistantID uint, offset, limit int) ([]model.CaptainDocument, int64, error) { var docs []model.CaptainDocument var count int64 db := r.db.WithContext(ctx).Model(&model.CaptainDocument{}).Where("assistant_id = ?", assistantID) db.Count(&count) - if err := db.Offset(offset).Limit(limit).Find(&docs).Error; err != nil { + if err := db.Preload("Assistant").Offset(offset).Limit(limit).Find(&docs).Error; err != nil { + return nil, 0, err + } + return docs, count, nil +} + +type CaptainDocumentListFilters struct { + AssistantID uint + Source string + Filter string + SearchKey string + Sort string + Offset int + Limit int +} + +func (r *CaptainDocumentRepo) ListByAccount(ctx context.Context, accountID uint, filters CaptainDocumentListFilters) ([]model.CaptainDocument, int64, error) { + var docs []model.CaptainDocument + var count int64 + db := r.db.WithContext(ctx).Model(&model.CaptainDocument{}).Where("account_id = ?", accountID) + if filters.AssistantID > 0 { + db = db.Where("assistant_id = ?", filters.AssistantID) + } + switch filters.Source { + case "web": + db = db.Where("external_link <> ''") + case "pdf": + db = db.Where("external_link = ''") + } + switch filters.Filter { + case "syncing": + db = db.Where("sync_status = ?", model.DocumentSyncStatusPending) + case "failed": + db = db.Where("sync_status = ?", model.DocumentSyncStatusFailed) + case "synced": + db = db.Where("sync_status = ?", model.DocumentSyncStatusSynced) + } + if filters.SearchKey != "" { + query := "%" + strings.ToLower(filters.SearchKey) + "%" + db = db.Where("LOWER(name) LIKE ? OR LOWER(external_link) LIKE ?", query, query) + } + if err := db.Count(&count).Error; err != nil { + return nil, 0, err + } + order := "updated_at DESC" + if filters.Sort == "recently_created" { + order = "created_at DESC" + } + if filters.Limit <= 0 { + filters.Limit = 25 + } + if err := db.Preload("Assistant").Order(order).Offset(filters.Offset).Limit(filters.Limit).Find(&docs).Error; err != nil { return nil, 0, err } return docs, count, nil @@ -65,4 +133,4 @@ func (r *CaptainDocumentRepo) FindByStatus(ctx context.Context, assistantID uint return nil, 0, err } return docs, count, nil -} \ No newline at end of file +} diff --git a/internal/service/captain_assistant_response_service.go b/internal/service/captain_assistant_response_service.go index 51a9401d..48b29619 100644 --- a/internal/service/captain_assistant_response_service.go +++ b/internal/service/captain_assistant_response_service.go @@ -8,8 +8,8 @@ import ( "github.com/gochat/gochat/internal/llm" "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" ) // CaptainAssistantResponseService processes assistant responses for conversations. @@ -48,17 +48,17 @@ func NewCaptainAssistantResponseService( type ProcessResponseRequest struct { ConversationID uint `json:"conversation_id" validate:"required"` AssistantID uint `json:"assistant_id" validate:"required"` - SendMessage bool `json:"send_message"` // if true, store response as outgoing message - ContentHint string `json:"content_hint,omitempty"` // optional context for response generation + SendMessage bool `json:"send_message"` // if true, store response as outgoing message + ContentHint string `json:"content_hint,omitempty"` // optional context for response generation } // ProcessResponseResult holds the generated response and its storage status. type ProcessResponseResult struct { Content string `json:"content"` - MessageID uint `json:"message_id,omitempty"` // set if send_message was true + MessageID uint `json:"message_id,omitempty"` // set if send_message was true AssistantID uint `json:"assistant_id"` ConversationID uint `json:"conversation_id"` - Sent bool `json:"sent"` // true if the message was stored + Sent bool `json:"sent"` // true if the message was stored } // --- Business logic --- @@ -136,7 +136,7 @@ Keep your response concise (max ~%d characters). } llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{ - Model: "", + Model: "", Messages: []llm.ChatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: conversationContext}, @@ -205,7 +205,7 @@ func (s *CaptainAssistantResponseService) List(ctx context.Context, accountID ui } if search != "" { searchTerm := "%" + search + "%" - db = db.Where("question ILIKE ? OR answer ILIKE ?", searchTerm, searchTerm) + db = db.Where("question LIKE ? OR answer LIKE ?", searchTerm, searchTerm) } // Eager load assistant and documentable — matches Chatwoot includes(:assistant, :documentable) @@ -227,13 +227,10 @@ func (s *CaptainAssistantResponseService) List(ctx context.Context, accountID ui // Get returns a single assistant response by ID. // Reference: Chatwoot Captain::AssistantResponsesController#show func (s *CaptainAssistantResponseService) Get(ctx context.Context, accountID uint, responseID uint) (*model.CaptainAssistantResponse, error) { - resp, err := s.responseRepo.GetByID(ctx, responseID) + resp, err := s.responseRepo.GetByAccountAndID(ctx, accountID, responseID) if err != nil { return nil, fmt.Errorf("get response: %w", err) } - if resp.AccountID != accountID { - return nil, fmt.Errorf("response does not belong to account") - } return resp, nil } @@ -269,6 +266,9 @@ func (s *CaptainAssistantResponseService) Create(ctx context.Context, accountID if err := s.responseRepo.Create(ctx, resp); err != nil { return nil, fmt.Errorf("create response: %w", err) } + if created, err := s.responseRepo.GetByAccountAndID(ctx, accountID, resp.ID); err == nil { + return created, nil + } return resp, nil } @@ -308,5 +308,5 @@ func (s *CaptainAssistantResponseService) Delete(ctx context.Context, accountID if err != nil { return err } - return s.responseRepo.Delete(ctx, resp.ID) + return s.responseRepo.DeleteByAccount(ctx, accountID, resp.ID) } diff --git a/internal/service/captain_bulk_action_service.go b/internal/service/captain_bulk_action_service.go index ed69d492..b5eed635 100644 --- a/internal/service/captain_bulk_action_service.go +++ b/internal/service/captain_bulk_action_service.go @@ -4,20 +4,22 @@ import ( "context" "encoding/json" "fmt" + "time" "github.com/gochat/gochat/internal/llm" + "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" ) // BulkActionType defines the type of bulk AI operation. type BulkActionType string const ( - BulkActionLabelSuggestion BulkActionType = "label_suggestion" - BulkActionReplySuggestion BulkActionType = "reply_suggestion" - BulkActionFollowUp BulkActionType = "follow_up" + BulkActionLabelSuggestion BulkActionType = "label_suggestion" + BulkActionReplySuggestion BulkActionType = "reply_suggestion" + BulkActionFollowUp BulkActionType = "follow_up" ) // CaptainBulkActionService handles batch AI operations on multiple conversations. @@ -30,6 +32,8 @@ type CaptainBulkActionService struct { llmProvider llm.Provider taskExtendedSvc *CaptainTaskExtendedService responseSvc *CaptainAssistantResponseService + responseRepo *repository.CaptainAssistantResponseRepo + documentRepo *repository.CaptainDocumentRepo } func NewCaptainBulkActionService( @@ -52,6 +56,11 @@ func NewCaptainBulkActionService( } } +func (s *CaptainBulkActionService) SetCaptainResourceRepos(responseRepo *repository.CaptainAssistantResponseRepo, documentRepo *repository.CaptainDocumentRepo) { + s.responseRepo = responseRepo + s.documentRepo = documentRepo +} + // --- Request/Response DTOs --- // BulkActionRequest is the input for a bulk AI operation. @@ -65,12 +74,27 @@ type BulkActionRequest struct { // BulkActionResult holds the results of a bulk operation. type BulkActionResult struct { - Action BulkActionType `json:"action"` + Action BulkActionType `json:"action"` Processed int `json:"processed"` Failed int `json:"failed"` Results []json.RawMessage `json:"results"` } +type ChatwootBulkActionRequest struct { + Type string `json:"type"` + IDs []uint `json:"ids"` + Fields struct { + Status string `json:"status"` + } `json:"fields"` +} + +type ChatwootBulkActionResult struct { + AssistantResponses []model.CaptainAssistantResponse + IDs []uint + Count int64 + Empty bool +} + // --- Business logic --- // Execute performs a bulk AI action on multiple conversations. @@ -102,6 +126,80 @@ func (s *CaptainBulkActionService) Execute(ctx context.Context, accountID uint, } } +func (s *CaptainBulkActionService) ExecuteChatwoot(ctx context.Context, accountID uint, req *ChatwootBulkActionRequest) (*ChatwootBulkActionResult, error) { + if req == nil || req.Type == "" || len(req.IDs) == 0 || req.Fields.Status == "" { + return nil, fmt.Errorf("invalid bulk action request") + } + + switch req.Type { + case "AssistantResponse": + return s.executeAssistantResponseBulk(ctx, accountID, req) + case "AssistantDocument": + return s.executeAssistantDocumentBulk(ctx, accountID, req) + default: + return nil, fmt.Errorf("unsupported bulk action type: %s", req.Type) + } +} + +func (s *CaptainBulkActionService) executeAssistantResponseBulk(ctx context.Context, accountID uint, req *ChatwootBulkActionRequest) (*ChatwootBulkActionResult, error) { + if s.responseRepo == nil { + return nil, fmt.Errorf("assistant response repository unavailable") + } + + db := s.responseRepo.DB().WithContext(ctx) + switch req.Fields.Status { + case "approve": + if err := db.Model(&model.CaptainAssistantResponse{}). + Where("account_id = ? AND id IN ? AND status = ?", accountID, req.IDs, model.ResponseStatusPending). + Update("status", model.ResponseStatusApproved).Error; err != nil { + return nil, fmt.Errorf("approve assistant responses: %w", err) + } + var responses []model.CaptainAssistantResponse + if err := db.Preload("Assistant").Where("account_id = ? AND id IN ?", accountID, req.IDs).Find(&responses).Error; err != nil { + return nil, fmt.Errorf("load assistant responses: %w", err) + } + return &ChatwootBulkActionResult{AssistantResponses: responses}, nil + case "delete": + if err := db.Where("account_id = ? AND id IN ?", accountID, req.IDs).Delete(&model.CaptainAssistantResponse{}).Error; err != nil { + return nil, fmt.Errorf("delete assistant responses: %w", err) + } + return &ChatwootBulkActionResult{Empty: true}, nil + default: + return nil, fmt.Errorf("unsupported assistant response status: %s", req.Fields.Status) + } +} + +func (s *CaptainBulkActionService) executeAssistantDocumentBulk(ctx context.Context, accountID uint, req *ChatwootBulkActionRequest) (*ChatwootBulkActionResult, error) { + if s.documentRepo == nil { + return nil, fmt.Errorf("document repository unavailable") + } + + db := s.documentRepo.DB().WithContext(ctx) + switch req.Fields.Status { + case "delete": + result := db.Where("account_id = ? AND id IN ?", accountID, req.IDs).Delete(&model.CaptainDocument{}) + if result.Error != nil { + return nil, fmt.Errorf("delete documents: %w", result.Error) + } + return &ChatwootBulkActionResult{Count: result.RowsAffected}, nil + case "sync": + now := time.Now().Unix() + result := db.Model(&model.CaptainDocument{}). + Where("account_id = ? AND id IN ?", accountID, req.IDs). + Updates(map[string]any{ + "sync_status": model.DocumentSyncStatusPending, + "last_sync_attempted_at": &now, + "last_sync_error_code": "", + }) + if result.Error != nil { + return nil, fmt.Errorf("sync documents: %w", result.Error) + } + return &ChatwootBulkActionResult{IDs: req.IDs, Count: result.RowsAffected}, nil + default: + return nil, fmt.Errorf("unsupported document status: %s", req.Fields.Status) + } +} + func (s *CaptainBulkActionService) bulkLabelSuggestion(ctx context.Context, accountID uint, req *BulkActionRequest) (*BulkActionResult, error) { query := &LabelSuggestionQuery{ ConversationIDs: req.ConversationIDs, diff --git a/internal/service/captain_custom_tool_service.go b/internal/service/captain_custom_tool_service.go index 1aa40f3c..3899eb96 100644 --- a/internal/service/captain_custom_tool_service.go +++ b/internal/service/captain_custom_tool_service.go @@ -20,12 +20,23 @@ import ( // CaptainCustomToolService implements business logic for CaptainCustomTool operations. // Reference: Chatwoot enterprise/app/controllers/api/v1/captain/custom_tools_controller.rb type CaptainCustomToolService struct { - toolRepo *repository.CaptainCustomToolRepo + toolRepo *repository.CaptainCustomToolRepo + httpClient HTTPDoer +} + +type HTTPDoer interface { + Do(req *http.Request) (*http.Response, error) } // NewCaptainCustomToolService creates a new CaptainCustomToolService. func NewCaptainCustomToolService(toolRepo *repository.CaptainCustomToolRepo) *CaptainCustomToolService { - return &CaptainCustomToolService{toolRepo: toolRepo} + return &CaptainCustomToolService{toolRepo: toolRepo, httpClient: &http.Client{Timeout: 30 * time.Second}} +} + +func (s *CaptainCustomToolService) SetHTTPClient(client HTTPDoer) { + if client != nil { + s.httpClient = client + } } // --- Request DTOs --- @@ -257,6 +268,11 @@ type ExecuteToolResult struct { Error string `json:"error,omitempty"` } +type TestToolResult struct { + Status int `json:"status"` + Body string `json:"body"` +} + // ExecuteTool calls the external HTTP endpoint configured in the custom tool. // Reference: Chatwoot Captain::CustomTool#execute func (s *CaptainCustomToolService) ExecuteTool(ctx context.Context, id uint, params map[string]interface{}) (*ExecuteToolResult, error) { @@ -298,8 +314,7 @@ func (s *CaptainCustomToolService) ExecuteTool(ctx context.Context, id uint, par } // Execute HTTP call - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(httpReq) + resp, err := s.httpClient.Do(httpReq) if err != nil { applogger.L().Errorf("ExecuteTool HTTP call: %v", err) return &ExecuteToolResult{Success: false, Error: err.Error()}, nil @@ -437,29 +452,96 @@ func applyAuth(req *http.Request, tool *model.CaptainCustomTool) error { // TestToolRequest is the DTO for testing a custom tool. type TestToolRequest struct { - ToolID uint `json:"tool_id" validate:"required"` - Params map[string]interface{} `json:"params"` + ToolID uint `json:"tool_id"` + Title string `json:"title"` + Description string `json:"description"` + EndpointURL string `json:"endpoint_url"` + HTTPMethod string `json:"http_method"` + AuthType string `json:"auth_type"` + AuthConfig json.RawMessage `json:"auth_config"` + ParamSchema json.RawMessage `json:"param_schema"` + RequestTemplate string `json:"request_template"` + ResponseTemplate string `json:"response_template"` + Params map[string]interface{} `json:"params"` } // TestTool tests a custom tool with given parameters without requiring it to be enabled. -func (s *CaptainCustomToolService) TestTool(ctx context.Context, accountID uint, req *TestToolRequest) (*ExecuteToolResult, error) { - tool, err := s.toolRepo.GetByID(ctx, req.ToolID) - if err != nil { - return nil, fmt.Errorf("custom tool not found: %w", err) +func (s *CaptainCustomToolService) TestTool(ctx context.Context, accountID uint, req *TestToolRequest) (*TestToolResult, error) { + tool := &model.CaptainCustomTool{ + AccountID: accountID, + Title: req.Title, + Description: req.Description, + EndpointURL: req.EndpointURL, + HTTPMethod: req.HTTPMethod, + AuthType: model.ToolAuthType(req.AuthType), + AuthConfig: req.AuthConfig, + ParamSchema: req.ParamSchema, + RequestTemplate: req.RequestTemplate, + ResponseTemplate: req.ResponseTemplate, + Enabled: true, + } + if req.ToolID != 0 { + stored, err := s.toolRepo.GetByID(ctx, req.ToolID) + if err != nil { + return nil, fmt.Errorf("custom tool not found: %w", err) + } + if stored.AccountID != accountID { + return nil, fmt.Errorf("custom tool does not belong to this account") + } + tool = stored + } + if tool.EndpointURL == "" { + return nil, fmt.Errorf("endpoint_url is required") + } + if tool.HTTPMethod == "" { + tool.HTTPMethod = "GET" + } + if tool.AuthType == "" { + tool.AuthType = model.ToolAuthTypeNone } - // Verify the tool belongs to the specified account - if tool.AccountID != accountID { - return nil, fmt.Errorf("custom tool does not belong to this account") - } - - // TestTool can execute even disabled tools (for validation before enabling) - result, err := s.ExecuteTool(ctx, req.ToolID, req.Params) + status, body, err := executeCustomToolHTTP(ctx, s.httpClient, tool, req.Params) if err != nil { - return &ExecuteToolResult{ - Success: false, - Error: fmt.Sprintf("test execution failed: %v", err), - }, nil + return nil, err } - return result, nil + if len(body) > 500 { + body = body[:500] + } + return &TestToolResult{Status: status, Body: body}, nil +} + +func executeCustomToolHTTP(ctx context.Context, client HTTPDoer, tool *model.CaptainCustomTool, params map[string]interface{}) (int, string, error) { + requestBody, err := buildRequestBody(tool.RequestTemplate, params) + if err != nil { + return 0, "", fmt.Errorf("build request body: %w", err) + } + + method := strings.ToUpper(tool.HTTPMethod) + var httpReq *http.Request + if method == "GET" || method == "DELETE" { + httpReq, err = http.NewRequestWithContext(ctx, method, tool.EndpointURL, nil) + } else { + httpReq, err = http.NewRequestWithContext(ctx, method, tool.EndpointURL, bytes.NewReader(requestBody)) + } + if err != nil { + return 0, "", fmt.Errorf("create HTTP request: %w", err) + } + if method != "GET" && method != "DELETE" { + httpReq.Header.Set("Content-Type", "application/json") + } + if err := applyAuth(httpReq, tool); err != nil { + return 0, "", fmt.Errorf("apply auth: %w", err) + } + + resp, err := client.Do(httpReq) + if err != nil { + return 0, "", err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return 0, "", fmt.Errorf("read response: %w", err) + } + return resp.StatusCode, string(respBody), nil } diff --git a/internal/service/captain_document_service.go b/internal/service/captain_document_service.go index f7997772..756471fa 100644 --- a/internal/service/captain_document_service.go +++ b/internal/service/captain_document_service.go @@ -18,19 +18,25 @@ import ( // CaptainDocumentService implements business logic for CaptainDocument operations. // Reference: Chatwoot enterprise/app/controllers/api/v1/captain/documents_controller.rb type CaptainDocumentService struct { - documentRepo *repository.CaptainDocumentRepo - llmProvider llm.Provider + documentRepo *repository.CaptainDocumentRepo + assistantRepo *repository.CaptainAssistantRepo + llmProvider llm.Provider } // NewCaptainDocumentService creates a new CaptainDocumentService. func NewCaptainDocumentService( documentRepo *repository.CaptainDocumentRepo, llmProvider llm.Provider, + assistantRepo ...*repository.CaptainAssistantRepo, ) *CaptainDocumentService { - return &CaptainDocumentService{ + s := &CaptainDocumentService{ documentRepo: documentRepo, llmProvider: llmProvider, } + if len(assistantRepo) > 0 { + s.assistantRepo = assistantRepo[0] + } + return s } // --- Request DTOs --- @@ -39,6 +45,7 @@ func NewCaptainDocumentService( type CreateDocumentRequest struct { Name string `json:"name" validate:"required"` ExternalLink string `json:"external_link" validate:"required"` + AssistantID uint `json:"assistant_id"` } // UpdateDocumentRequest is the DTO for updating a document. @@ -47,22 +54,40 @@ type UpdateDocumentRequest struct { ExternalLink string `json:"external_link"` } +type ListDocumentsRequest struct { + AssistantID uint + Page int + PerPage int + Filter string + Source string + Sort string + SearchKey string +} + // --- CRUD Operations --- // Create creates a new CaptainDocument. func (s *CaptainDocumentService) Create(ctx context.Context, assistantID, accountID uint, req *CreateDocumentRequest) (*model.CaptainDocument, error) { + if s.assistantRepo != nil { + if _, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, assistantID); err != nil { + return nil, fmt.Errorf("assistant not found: %w", err) + } + } doc := &model.CaptainDocument{ - AccountID: accountID, - AssistantID: assistantID, - Name: req.Name, + AccountID: accountID, + AssistantID: assistantID, + Name: req.Name, ExternalLink: req.ExternalLink, - Status: model.DocumentStatusPending, + Status: model.DocumentStatusPending, } if err := s.documentRepo.Create(ctx, doc); err != nil { applogger.L().Errorf("Create captain document: %v", err) return nil, fmt.Errorf("create document: %w", err) } + if created, err := s.documentRepo.GetByAccountAndID(ctx, accountID, doc.ID); err == nil { + return created, nil + } return doc, nil } @@ -76,6 +101,15 @@ func (s *CaptainDocumentService) Get(ctx context.Context, id uint) (*model.Capta return doc, nil } +func (s *CaptainDocumentService) GetByAccount(ctx context.Context, accountID, id uint) (*model.CaptainDocument, error) { + doc, err := s.documentRepo.GetByAccountAndID(ctx, accountID, id) + if err != nil { + applogger.L().Errorf("Get captain document: %v", err) + return nil, fmt.Errorf("get document: %w", err) + } + return doc, nil +} + // Update updates an existing document. func (s *CaptainDocumentService) Update(ctx context.Context, id uint, req *UpdateDocumentRequest) (*model.CaptainDocument, error) { doc, err := s.documentRepo.GetByID(ctx, id) @@ -106,6 +140,17 @@ func (s *CaptainDocumentService) Delete(ctx context.Context, id uint) error { return nil } +func (s *CaptainDocumentService) DeleteByAccount(ctx context.Context, accountID, id uint) error { + if _, err := s.documentRepo.GetByAccountAndID(ctx, accountID, id); err != nil { + return fmt.Errorf("document not found: %w", err) + } + if err := s.documentRepo.DeleteByAccount(ctx, accountID, id); err != nil { + applogger.L().Errorf("Delete captain document: %v", err) + return fmt.Errorf("delete document: %w", err) + } + return nil +} + // List retrieves documents for an assistant with pagination. func (s *CaptainDocumentService) List(ctx context.Context, assistantID uint, offset, limit int) ([]model.CaptainDocument, int64, error) { docs, count, err := s.documentRepo.ListByAssistant(ctx, assistantID, offset, limit) @@ -116,6 +161,46 @@ func (s *CaptainDocumentService) List(ctx context.Context, assistantID uint, off return docs, count, nil } +func (s *CaptainDocumentService) ListByAccount(ctx context.Context, accountID uint, req ListDocumentsRequest) ([]model.CaptainDocument, int64, int, error) { + page := req.Page + if page <= 0 { + page = 1 + } + perPage := req.PerPage + if perPage <= 0 { + perPage = 25 + } + docs, count, err := s.documentRepo.ListByAccount(ctx, accountID, repository.CaptainDocumentListFilters{ + AssistantID: req.AssistantID, + Source: req.Source, + Filter: req.Filter, + SearchKey: req.SearchKey, + Sort: req.Sort, + Offset: (page - 1) * perPage, + Limit: perPage, + }) + if err != nil { + applogger.L().Errorf("List captain documents: %v", err) + return nil, 0, page, fmt.Errorf("list documents: %w", err) + } + return docs, count, page, nil +} + +func (s *CaptainDocumentService) MarkSyncing(ctx context.Context, accountID, id uint) (*model.CaptainDocument, error) { + doc, err := s.documentRepo.GetByAccountAndID(ctx, accountID, id) + if err != nil { + return nil, fmt.Errorf("document not found: %w", err) + } + now := time.Now().Unix() + doc.LastSyncAttemptedAt = &now + doc.SyncStatus = model.DocumentSyncStatusPending + doc.LastSyncErrorCode = "" + if err := s.documentRepo.Update(ctx, doc); err != nil { + return nil, fmt.Errorf("mark document syncing: %w", err) + } + return s.documentRepo.GetByAccountAndID(ctx, accountID, id) +} + // --- Document Processing --- // ProcessDocument extracts content and generates embedding for a document. @@ -256,4 +341,4 @@ func computeFingerprint(content string) string { h := sha256.New() h.Write([]byte(content)) return fmt.Sprintf("%x", h.Sum(nil)) -} \ No newline at end of file +}