diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 38e11210..565bedf5 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -37,10 +37,10 @@ Hermes plan landing map: ## Current Baseline -- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(inboxes): align agent bot member actions`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(inboxes): align agent bot member actions`. +- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(help-center): queue article translations`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(help-center): queue article translations`. - Latest documentation/tooling checkpoint: this checkpoint, prepared as `docs: expand help center route parity tracking`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`. -- Worktree status at this implementation checkpoint: P6.5 inbox agent-bot member actions now match Chatwoot `InboxesController#agent_bot`/`#set_agent_bot` and the reused dashboard `agentBots` store path: `set_agent_bot` accepts the frontend `{ agent_bot: id|null }` body, returns empty `200 OK`, disconnect destroys the `agent_bot_inboxes` row, reassign updates the inbox's single binding, and `agent_bot` returns the Chatwoot `{ agent_bot: ... }` partial shape with `{}` when disconnected. P6 message retry parity already matches Chatwoot `MessagesController#retry`: retry sets the message status back to `sent`, clears provider failure data from `content_attributes`, and queues the existing durable `SendReplyJob` path when a worker pool is configured. P6 conversation custom-attribute response parity already matches Chatwoot `ConversationsController#custom_attributes`, `custom_attributes.json.jbuilder`, and the reused dashboard conversation store action. Route parity tracking includes the Chatwoot help-center portal/category/article route family from `reference/chatwoot/config/routes.rb:385-404`, so the audited critical set covers portal CRUD/member actions, nested categories, nested articles, and article bulk actions. P3.7c help center article parity matches the reused dashboard `helpCenter/articles.js` client and Chatwoot `ArticlesController`/`Articles::BulkActionsController`/Jbuilder surface. Campaign create/update already mirrors Chatwoot `Campaign#ensure_correct_campaign_attributes`; account campaign CRUD already matches the reused dashboard `campaigns` store and Chatwoot Jbuilder contract. Agent/settings/profile/notification/contact/custom-field/report enterprise drifts listed below remain at their prior checkpoint states. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is another Phase 6 placeholder burn-down item or a new Phase 2/3 drift slice from fresh reference/smoke evidence. +- Worktree status at this implementation checkpoint: P3.7d enterprise help-center article translation now matches the reused dashboard `bulkTranslate` route and Chatwoot enterprise `Articles::BulkActionsController#translate`/`Captain::Articles::TranslateJob` boundary: `POST /articles/bulk_actions/translate` validates `captain_tasks`, allowed portal locale, optional locale-scoped category, and article IDs; returns `409 { duplicate_articles }` unless `force` is true; enqueues durable low-priority `captain:article_translate` jobs; and replays translation through an LLM backend using Chatwoot's title/content prompts before creating or updating draft translated articles. P6.5 inbox agent-bot member actions, P6 message retry parity, P6 conversation custom-attribute response parity, and P3.7a-P3.7c help center portal/category/article payload parity remain in Review. Campaign create/update already mirrors Chatwoot `Campaign#ensure_correct_campaign_attributes`; account campaign CRUD already matches the reused dashboard `campaigns` store and Chatwoot Jbuilder contract. Agent/settings/profile/notification/contact/custom-field/report enterprise drifts listed below remain at their prior checkpoint states. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is another Phase 6 placeholder burn-down item or a new Phase 2/3 drift slice from fresh reference/smoke evidence. - `go test ./...` passes. - Route dump succeeds with `TOTAL: 860` after adding no-trailing-slash article index/create aliases, frontend-used article PATCH update route, and frontend article bulk-action routes. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -86,7 +86,7 @@ Open work after the current checkpoint: | --- | --- | --- | --- | | Phase 5 jobs | Keep B9.3 in Review and open only evidence-backed drift slices for new async gaps. | `Phase 5: Background Jobs And Integrations` | Automation tests prove remaining action side effects and reference inspection proves no delayed action params remain. | | B12 smoke | Run optional live API/browser/enterprise smoke in a full PostgreSQL/Redis/Meilisearch/Vite environment and convert failures into named slices. | `B12 reused frontend verification breakdown` | `docs/parity/frontend_smoke_report.md` records checked pass/fail results and maps failures to slices. | -| P3.7 help center | Portal, category, and article route/payload parity are in Review for the inspected dashboard client paths. | `Open Checkpoint Contracts`, `Phase 3` | Reused Chatwoot help-center dashboard clients can call portal/category/article APIs without adapters. | +| P3.7 help center | Portal, category, article route/payload parity, and enterprise article bulk translation are in Review for the inspected dashboard client paths. | `Open Checkpoint Contracts`, `Phase 3` | Reused Chatwoot help-center dashboard clients can call portal/category/article APIs and bulk article translation without adapters. | | Phase 2/3 drift | Help-center portal/category/article routes from `routes.rb:385-404` are now in the tracked critical set; expand the next route/serializer fixtures only from B12 failures or fresh reference/frontend inspection. | `Phase 2`, `Phase 3`, `docs/parity/` | Route parity remains 0 missing for tracked frontend routes; serializers have reference fixtures. | | Phase 6 placeholders | Re-run placeholder audit and burn down any frontend-reachable stub. | `Phase 6: Core Product Placeholder Burn-down` | Stub list has no reused-frontend critical path without a named owner. | @@ -130,7 +130,8 @@ These rows are the executable development plan from this point forward. A checkp | P3.6 custom filters/custom attribute definitions payload parity | `internal/handler/api/v1/custom_filter_handler.go`, `internal/service/custom_filter_service.go`, `internal/repository/custom_filter_repo.go`, `internal/handler/api/v1/custom_attribute_definition_handler.go`, `internal/service/custom_attribute_definition_service.go`, `internal/service/custom_attribute_value_service.go`, handler/service/repository tests | `reference/chatwoot/app/controllers/api/v1/accounts/custom_filters_controller.rb`, `app/views/api/v1/accounts/custom_filters/*.json.jbuilder`, `app/views/api/v1/models/_custom_filter.json.jbuilder`, dashboard `api/customViews.js`, `store/modules/customViews.js`, `custom_attribute_definitions_controller.rb`, `_custom_attribute_definition.json.jbuilder`, dashboard `api/attributes.js`, `store/modules/attributes.js`, settings attributes screens/constants | `/custom_filters` and `/custom_attribute_definitions` now return raw Chatwoot arrays/objects instead of local envelopes, accept raw reused-frontend bodies plus Rails-style wrappers, use `200 OK` mutation responses, preserve `204` deletes, scope saved views by current user and default `filter_type=conversation`, normalize custom attribute enum integers to Chatwoot string values, and keep custom attribute value validation compatible with normalized `*_attribute` definitions plus legacy rows. | Review by `feat(custom-fields): align chatwoot saved view payloads`; focused handler/service/repository tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` must pass. | | P3.7a help center portal route/payload parity | `internal/router/router.go`, `internal/handler/api/v1/portal_handler.go`, `internal/service/portal_service.go`, `internal/repository/portal_repo.go`, portal handler/service/repository tests | `reference/chatwoot/app/controllers/api/v1/accounts/portals_controller.rb`, `app/views/api/v1/accounts/portals/*.json.jbuilder`, dashboard `api/helpCenter/portals.js`, help-center portal store/actions | Frontend no-trailing-slash `GET/POST /portals`, `PATCH /portals/:portal_id`, and PATCH archive routes are registered; `:portal_id` resolves as Chatwoot portal slug with legacy numeric fallback; raw portal bodies plus `{ portal: ... }` wrappers are accepted; portal index/show/create/update return raw Jbuilder-style payloads; delete/logo/archive return empty `200 OK`; `send_instructions` returns `{ message }`; and `ssl_status` returns enterprise `{ status, verification_errors }`/`422` semantics. | Review by `feat(help-center): align portal payloads`; focused portal handler/service/repository tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. | | P3.7b help center category route/payload parity | `internal/router/router.go`, `internal/handler/api/v1/category_handler.go`, `internal/service/category_service.go`, `internal/repository/category_repo.go`, category tests | `reference/chatwoot/app/controllers/api/v1/accounts/categories_controller.rb`, `app/views/api/v1/accounts/categories/*.json.jbuilder`, dashboard `api/helpCenter/categories.js` | Category APIs now resolve portal slugs account-scoped for `/portals/:portal_id/categories`, accept raw category bodies plus wrappers, expose frontend no-trailing-slash and `PATCH` routes, return Chatwoot `{ payload, meta }` and `{ payload: category }` serializers with locale filtering and article counts, make delete/reorder empty `200 OK`, accept Chatwoot `positions_hash`, and keep category mutations scoped to the resolved portal. | Review by `feat(help-center): align category payloads`; focused category handler/service/repository tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. | -| P3.7c help center article and bulk-action parity | `internal/router/router.go`, `internal/handler/api/v1/article_handler.go`, `internal/service/article_service.go`, `internal/repository/article_repo.go`, article tests | `reference/chatwoot/app/controllers/api/v1/accounts/articles_controller.rb`, `app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb`, `app/views/api/v1/accounts/articles/*.json.jbuilder`, dashboard `api/helpCenter/articles.js` | Article APIs now resolve portal slugs account-scoped with numeric fallback, accept raw article bodies plus wrappers, expose no-trailing-slash index/create plus frontend `PATCH`, return Chatwoot `{ payload, meta }` list/search payloads and `{ payload: article }` show/create/update/edit payloads, serialize category/author/views/Unix updated-at/meta/associated articles, make delete/reorder/bulk actions empty `200 OK`, accept Chatwoot `positions_hash`, scope mutations to the resolved portal, and implement frontend bulk update-status/update-category/delete routes plus `translate` `501`. | Review by `feat(help-center): align article payloads`; focused article handler tests, service/repository article tests, router test, route dump/parity regeneration, escalated full `go test ./...`, and `git diff --check` passed. | +| P3.7c help center article and bulk-action parity | `internal/router/router.go`, `internal/handler/api/v1/article_handler.go`, `internal/service/article_service.go`, `internal/repository/article_repo.go`, article tests | `reference/chatwoot/app/controllers/api/v1/accounts/articles_controller.rb`, `app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb`, `app/views/api/v1/accounts/articles/*.json.jbuilder`, dashboard `api/helpCenter/articles.js` | Article APIs now resolve portal slugs account-scoped with numeric fallback, accept raw article bodies plus wrappers, expose no-trailing-slash index/create plus frontend `PATCH`, return Chatwoot `{ payload, meta }` list/search payloads and `{ payload: article }` show/create/update/edit payloads, serialize category/author/views/Unix updated-at/meta/associated articles, make delete/reorder/bulk actions empty `200 OK`, accept Chatwoot `positions_hash`, scope mutations to the resolved portal, and implement frontend bulk update-status/update-category/delete routes. | Review by `feat(help-center): align article payloads`; focused article handler tests, service/repository article tests, router test, route dump/parity regeneration, escalated full `go test ./...`, and `git diff --check` passed. | +| P3.7d enterprise article bulk translation | `internal/handler/api/v1/article_handler.go`, `internal/service/article_service.go`, `internal/repository/article_repo.go`, `internal/app/bootstrap.go`, article handler/service tests | `reference/chatwoot/enterprise/app/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller.rb`, `enterprise/app/jobs/captain/articles/translate_job.rb`, `enterprise/app/services/captain/llm/article_translation_service.rb`, dashboard `api/helpCenter/articles.js`, `BulkTranslateDialog.vue` | Article bulk translate now validates `captain_tasks`, portal allowed locales, optional target-locale category, and selected articles; detects existing root-article translations and returns `409 { duplicate_articles }` unless `force` is true; queues durable low-priority `captain:article_translate` jobs; maps locale codes to English language names; and creates or updates draft translated articles through the wired LLM translation backend using Chatwoot's title/content prompts. | Review by `feat(help-center): queue article translations`; focused service/handler tests, combined service/handler/repository/app tests, full `go test ./...`, and `git diff --check` passed. No route changes. | | P6 conversation transcript response parity | `internal/handler/api/v1/conversation_handler.go`, conversation handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb`, dashboard `api/inbox/conversation.js` | Account conversation transcript now follows Chatwoot's controller contract: missing email returns `422 { error: "email param missing" }`, nonblank email schedules through the existing service boundary and returns empty `200 OK`, and invalid-looking but nonblank email values are not rejected by local email format validation. | Review by `feat(conversations): align transcript responses`; focused transcript handler/service tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` must pass. | | P6 conversation custom attributes response parity | `internal/handler/api/v1/conversation_handler.go`, conversation handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb`, `app/views/api/v1/accounts/conversations/custom_attributes.json.jbuilder`, dashboard `api/inbox/conversation.js`, conversation store action | Account conversation custom attribute updates now return Chatwoot `{ custom_attributes: ... }` only, matching the store action that reads `response.data.custom_attributes`, instead of returning the full conversation serializer with unrelated fields. Empty/null JSON serializes as `{}`. | Review by `feat(conversations): align custom attribute response`; focused handler tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` passed. | | P6 message retry status parity | `internal/service/message_service.go`, `internal/handler/api/v1/message_handler.go`, message service/handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations/messages_controller.rb`, `app/views/api/v1/accounts/conversations/messages/retry.json.jbuilder`, dashboard `api/inbox/message.js` | Account message retry now follows Chatwoot: status resets to `sent`, `content_attributes` is cleared to `{}`, response returns the message serializer, and the existing durable SendReplyJob path is queued when a worker pool is configured. | Review by `feat(messages): align retry status parity`; focused retry service/handler tests, combined handler/service tests, full `go test ./...`, and `git diff --check` must pass. | @@ -169,6 +170,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(help-center): queue article translations` | Advances P3.7d enterprise help-center parity by matching Chatwoot enterprise `Articles::BulkActionsController#translate`, `Captain::Articles::TranslateJob`, `Captain::Llm::ArticleTranslationService`, and the reused dashboard `bulkTranslate` flow. Bulk translate now enforces `captain_tasks`, portal allowed locales, optional locale-matched categories, and article selection; returns `409 { duplicate_articles }` for existing translations unless `force` is true; enqueues low-priority durable `captain:article_translate` jobs; registers replay in bootstrap; and uses a wired LLM-backed article translator with Chatwoot title/content prompts and locale-to-English target language names before creating or updating draft translated articles. | `go test ./internal/service -run 'ArticleService_.*Translate\|LLMArticleTranslationBackend\|ArticleService_BulkActions\|ArticleService_BulkUpdateStatus\|ArticleService_BulkDelete' -count=1`; `go test ./internal/handler/api/v1 -run 'ArticleHandlerSuite/TestBulkTranslate\|ArticleHandlerSuite/TestBulkActions' -count=1`; `go test ./internal/service ./internal/handler/api/v1 ./internal/repository ./internal/app -run 'Article\|Bootstrap\|App' -count=1`; `go test ./...`; `git diff --check`. No route changes. | P3.7d moves to Review; continue Phase 6 placeholder burn-down or the next evidence-backed Phase 2/3 drift slice. | | `feat(inboxes): align agent bot member actions` | Advances P6.5 inbox API parity by aligning `GET /inboxes/:id/agent_bot` and `POST /inboxes/:id/set_agent_bot` with Chatwoot `InboxesController#agent_bot`/`#set_agent_bot`, `inboxes/agent_bot.json.jbuilder`, `_agent_bot.json.jbuilder`, and the reused dashboard `agentBots` store/API path. The setter accepts Chatwoot `{ agent_bot: id|null }` bodies, keeps legacy `{ agent_bot_id }` compatibility, treats omitted/null params as disconnect, returns empty `200 OK`, destroys disconnected bindings instead of leaving inactive local rows, and updates the inbox's single binding on reassignment. The getter now returns `{ agent_bot: {} }` when disconnected and serializes assigned bots with Chatwoot fields such as `thumbnail`, `bot_type`, `bot_config`, and `outgoing_url`. | `go test ./internal/service -run 'InboxService_SetAgentBot' -count=1`; `go test ./internal/handler/api/v1 -run 'InboxHandler_Set.*AgentBot\|InboxHandler_GetAgentBot\|InboxSetAgentBot' -count=1`; `go test ./internal/handler/api/v1 ./internal/service -run 'Inbox.*AgentBot\|SetAgentBot' -count=1`; `go test ./...`; `git diff --check`. No route changes. | P6.5 remains Doing for additional inbox/channel drift; continue Phase 6 placeholder audit or the next evidence-backed inbox member action gap. | | `feat(messages): align retry status parity` | Advances P6 message API parity by matching Chatwoot `MessagesController#retry`, `messages/retry.json.jbuilder`, and the reused dashboard `MessageApi.retry` path. Message retry now resets failed messages to `sent`, clears `content_attributes` to `{}`, returns the Chatwoot message serializer, and queues the existing durable `message:send_reply` path when a WorkerPool is configured instead of leaving local `retrying` state/provider failure details visible. | `go test ./internal/service -run MessageService_Retry -count=1`; `go test ./internal/handler/api/v1 -run 'MessageHandlerTestSuite/TestRetry' -count=1`; `go test ./internal/handler/api/v1 ./internal/service -run 'Message.*Retry' -count=1`; `go test ./...`; `git diff --check`. | P6.4 message retry drift moves to Review; continue Phase 6 placeholder burn-down or fresh evidence-backed Phase 2/3 drift. | | `feat(conversations): align custom attribute response` | Advances P6 conversation response parity by matching Chatwoot `ConversationsController#custom_attributes`, `custom_attributes.json.jbuilder`, and the reused dashboard `ConversationApi.updateCustomAttributes` store action. The account conversation custom-attributes route now returns only `{ custom_attributes: ... }`, excludes local conversation/payload/meta wrappers, and converts empty/null JSON to `{}` for frontend merge stability. The placeholder audit was rerun and still shows only webhook handler-not-injected fallbacks calling `chatwootParityStub`. | `go test ./internal/handler/api/v1 -run 'ConversationHandler.*UpdateCustomAttributes\|TestConversationHandler' -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/router -run 'UpdateCustomAttributes\|ConversationHandler\|ConversationService\|Router' -count=1`; `rg -n "chatwootParityStub\|TODO.*placeholder\|placeholder\|stub" internal/handler internal/service internal/router -g'*.go'`; `go test ./...`; `git diff --check`. | P6 conversation custom-attribute response drift moves to Review; continue Phase 6 placeholder burn-down from the remaining webhook nil-handler fallbacks or fresh reference/smoke evidence. | @@ -2124,6 +2126,7 @@ Verification milestone gates: ## Progress Log +- 2026-06-06: P3.7d enterprise article translation checkpoint prepared as `feat(help-center): queue article translations`; audited Chatwoot enterprise `Articles::BulkActionsController#translate`, `Captain::Articles::TranslateJob`, `Captain::Llm::ArticleTranslationService`, reused dashboard `api/helpCenter/articles.js`, and `BulkTranslateDialog.vue`. Account article bulk translate now validates `captain_tasks`, allowed portal locale, optional locale-scoped category, and selected articles; detects existing root-article translations and returns `409 { duplicate_articles }` unless `force` is true; queues durable low-priority `captain:article_translate` jobs; replays jobs by creating or updating draft translated articles; and wires an LLM-backed translation backend in bootstrap with Chatwoot title/content prompts and English target language names. Focused service/handler tests, combined service/handler/repository/app tests, full `go test ./...`, and `git diff --check` passed; no route artifacts changed. - 2026-06-06: P6.5 inbox agent-bot checkpoint prepared as `feat(inboxes): align agent bot member actions`; audited Chatwoot `InboxesController#agent_bot`, `InboxesController#set_agent_bot`, `inboxes/agent_bot.json.jbuilder`, `_agent_bot.json.jbuilder`, and the reused dashboard `agentBots` store/API. `POST /inboxes/:id/set_agent_bot` now accepts the frontend `{ agent_bot: id|null }` body, keeps legacy `{ agent_bot_id }` compatibility, treats omitted/null bodies as disconnect, returns empty `200 OK`, destroys disconnected bindings like Chatwoot `@inbox.agent_bot_inbox.destroy!`, and updates the inbox's single binding when reassigning bots. `GET /inboxes/:id/agent_bot` now returns `{ agent_bot: {} }` when disconnected and Chatwoot partial fields such as `thumbnail`, `bot_type`, `bot_config`, and `outgoing_url` when assigned. Focused inbox service/handler tests, combined handler/service tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder burn-down or the next evidence-backed inbox drift. - 2026-06-06: P6 message retry checkpoint prepared as `feat(messages): align retry status parity`; audited Chatwoot `MessagesController#retry`, `messages/retry.json.jbuilder`, and reused dashboard `api/inbox/message.js`. Account message retry now resets status to `sent`, clears `content_attributes` to `{}`, returns the normal Chatwoot message serializer, and queues the existing durable send-reply job path when a WorkerPool is configured instead of exposing local `retrying` state/provider failure details. Focused retry service/handler tests, combined handler/service retry tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder burn-down or a fresh evidence-backed Phase 2/3 drift slice. - 2026-06-06: P6 conversation custom-attribute response checkpoint prepared as `feat(conversations): align custom attribute response`; audited Chatwoot `ConversationsController#custom_attributes`, `custom_attributes.json.jbuilder`, reused dashboard `api/inbox/conversation.js`, and the conversation store action that reads `response.data.custom_attributes`. Account conversation custom-attribute updates now return only `{ custom_attributes: ... }`, exclude local conversation/payload/meta wrappers, and serialize empty/null custom attributes as `{}` for frontend merge stability. Focused handler tests, combined handler/service/router tests, placeholder audit, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder burn-down or a fresh evidence-backed Phase 2/3 drift slice. diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 61ed5869..dfdf1cf4 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -632,6 +632,8 @@ func Bootstrap(env string) (*App, error) { portalService := service.NewPortalService(portalRepo) categoryService := service.NewCategoryService(categoryRepo, relatedCategoryRepo) articleService := service.NewArticleService(articleRepo) + articleService.SetWorkerPool(workerPool) + articleService.SetArticleTranslationBackend(service.NewLLMArticleTranslationBackend(llmProvider)) folderService := service.NewFolderService(folderRepo) portalMemberService := service.NewPortalMemberService(portalMemberRepo) diff --git a/internal/handler/api/v1/article_handler.go b/internal/handler/api/v1/article_handler.go index acbfd153..a36dde55 100644 --- a/internal/handler/api/v1/article_handler.go +++ b/internal/handler/api/v1/article_handler.go @@ -386,7 +386,25 @@ func (h *ArticleHandler) BulkDelete(c *gin.Context) { } func (h *ArticleHandler) BulkTranslate(c *gin.Context) { - c.Status(http.StatusNotImplemented) + portal, ok := h.resolvePortalForRequest(c) + if !ok { + return + } + var req service.BulkTranslateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) + return + } + if err := h.svc.BulkTranslate(c.Request.Context(), portal.AccountID, portal, currentUserID(c), req); err != nil { + var conflict *service.ArticleTranslationConflictError + if errors.As(err, &conflict) { + c.JSON(http.StatusConflict, gin.H{"duplicate_articles": conflict.Duplicates}) + return + } + renderArticleBulkError(c, err) + return + } + c.Status(http.StatusOK) } // BulkActions performs a bulk operation (publish/archive/delete) on multiple articles. @@ -595,6 +613,10 @@ func renderArticleBulkError(c *gin.Context, err error) { message = "Invalid status" case errors.Is(err, service.ErrArticleBulkCategoryNotFound): message = "Category not found" + case errors.Is(err, service.ErrArticleBulkCaptainNotAvailable): + message = "Captain is not available" + case errors.Is(err, service.ErrArticleBulkLocaleNotAvailable): + message = "Locale is not available" default: message = err.Error() } diff --git a/internal/handler/api/v1/article_handler_test.go b/internal/handler/api/v1/article_handler_test.go index e6b55715..cad5db54 100644 --- a/internal/handler/api/v1/article_handler_test.go +++ b/internal/handler/api/v1/article_handler_test.go @@ -12,6 +12,7 @@ import ( "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" + "github.com/gochat/gochat/internal/worker" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "gorm.io/driver/sqlite" @@ -38,11 +39,13 @@ func (s *ArticleHandlerTestSuite) SetupSuite() { &model.Account{}, &model.User{}, &model.Portal{}, &model.Category{}, &model.Folder{}, &model.Article{}, &model.PortalMember{}, + &model.BackgroundJob{}, )) s.db = db repo := repository.NewArticleRepo(db) svc := service.NewArticleService(repo) + svc.SetWorkerPool(worker.NewWorkerPool(db)) portalRepo := repository.NewPortalRepo(db) s.handler = NewArticleHandler(svc, service.NewPortalService(portalRepo)) @@ -387,6 +390,71 @@ func (s *ArticleHandlerTestSuite) TestBulkActions_FrontendRoutes() { assert.Equal(s.T(), http.StatusOK, w.Code) } +func (s *ArticleHandlerTestSuite) TestBulkTranslate_FrontendRouteQueuesAndReturnsConflict() { + account := &model.Account{Name: "translate-account", FeatureFlags: `{"captain_tasks":true}`} + s.Require().NoError(s.db.Create(account).Error) + portal := &model.Portal{AccountID: account.ID, Name: "translate-portal", Slug: "translate-portal", Locale: "en", PortalConfiguration: json.RawMessage(`{"allowed_locales":["en","fr"]}`)} + s.Require().NoError(s.db.Create(portal).Error) + category := &model.Category{AccountID: account.ID, PortalID: portal.ID, Name: "French", Slug: "french", Locale: "fr"} + s.Require().NoError(s.db.Create(category).Error) + article := &model.Article{AccountID: account.ID, PortalID: portal.ID, Title: "Returns", Slug: "returns-translate", Status: "published", Locale: "en"} + s.Require().NoError(s.db.Create(article).Error) + duplicate := &model.Article{AccountID: account.ID, PortalID: portal.ID, Title: "Retours", Slug: "retours-translate", Status: "draft", Locale: "fr", AssociatedArticleID: &article.ID} + s.Require().NoError(s.db.Create(duplicate).Error) + + r := gin.New() + r.POST("/api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions/translate", s.handler.BulkTranslate) + + body := fmt.Sprintf(`{"ids":[%d],"locale":"fr","category_id":%d}`, article.ID, category.ID) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/portals/translate-portal/articles/bulk_actions/translate", account.ID), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + assert.Equal(s.T(), http.StatusConflict, w.Code) + var conflict map[string][]map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &conflict)) + s.Require().Len(conflict["duplicate_articles"], 1) + assert.Equal(s.T(), float64(duplicate.ID), conflict["duplicate_articles"][0]["id"]) + + body = fmt.Sprintf(`{"ids":[%d],"locale":"fr","category_id":%d,"force":true}`, article.ID, category.ID) + w = httptest.NewRecorder() + req, _ = http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/portals/translate-portal/articles/bulk_actions/translate", account.ID), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + assert.Equal(s.T(), http.StatusOK, w.Code) + assert.Empty(s.T(), w.Body.String()) + + var jobs int64 + s.Require().NoError(s.db.Model(&model.BackgroundJob{}).Where("job_type = ? AND queue = ?", service.TaskTypeCaptainArticleTranslate, "low").Count(&jobs).Error) + assert.GreaterOrEqual(s.T(), jobs, int64(1)) +} + +func (s *ArticleHandlerTestSuite) TestBulkTranslate_ValidatesCaptainLocaleAndCategory() { + account := &model.Account{Name: "translate-invalid-account", FeatureFlags: `{"captain_tasks":true}`} + s.Require().NoError(s.db.Create(account).Error) + portal := &model.Portal{AccountID: account.ID, Name: "translate-invalid", Slug: "translate-invalid", Locale: "en", PortalConfiguration: json.RawMessage(`{"allowed_locales":["en","fr"]}`)} + s.Require().NoError(s.db.Create(portal).Error) + article := &model.Article{AccountID: account.ID, PortalID: portal.ID, Title: "Billing", Slug: "billing-translate-invalid", Status: "published", Locale: "en"} + s.Require().NoError(s.db.Create(article).Error) + + r := gin.New() + r.POST("/api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions/translate", s.handler.BulkTranslate) + + w := httptest.NewRecorder() + body := fmt.Sprintf(`{"ids":[%d],"locale":"es"}`, article.ID) + req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/portals/translate-invalid/articles/bulk_actions/translate", account.ID), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code) + + w = httptest.NewRecorder() + body = fmt.Sprintf(`{"ids":[%d],"locale":"fr","category_id":999999}`, article.ID) + req, _ = http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/portals/translate-invalid/articles/bulk_actions/translate", account.ID), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code) +} + func (s *ArticleHandlerTestSuite) TestBulkUpdateStatus_InvalidStatusReturnsChatwootError() { article := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "bulk-invalid", Slug: "bulk-invalid", Status: "draft"} s.Require().NoError(s.db.Create(article).Error) diff --git a/internal/repository/article_repo.go b/internal/repository/article_repo.go index c409fdad..a0174ed3 100644 --- a/internal/repository/article_repo.go +++ b/internal/repository/article_repo.go @@ -18,6 +18,8 @@ func NewArticleRepo(db *gorm.DB) *ArticleRepo { return &ArticleRepo{db: db} } +func (r *ArticleRepo) DB() *gorm.DB { return r.db } + func (r *ArticleRepo) Create(ctx context.Context, article *model.Article) error { return r.db.WithContext(ctx).Create(article).Error } @@ -38,6 +40,14 @@ func (r *ArticleRepo) GetByPortalAndID(ctx context.Context, portalID, id uint) ( return &article, nil } +func (r *ArticleRepo) GetByAccountAndID(ctx context.Context, accountID, id uint) (*model.Article, error) { + var article model.Article + if err := r.articlePreloads(r.db.WithContext(ctx)).Where("account_id = ?", accountID).First(&article, id).Error; err != nil { + return nil, err + } + return &article, nil +} + func (r *ArticleRepo) Update(ctx context.Context, article *model.Article) error { return r.db.WithContext(ctx).Save(article).Error } @@ -303,6 +313,44 @@ func (r *ArticleRepo) CountByPortalAndIDs(ctx context.Context, portalID uint, id return count, nil } +func (r *ArticleRepo) FindByPortalAndIDs(ctx context.Context, portalID uint, ids []uint) ([]model.Article, error) { + var articles []model.Article + if len(ids) == 0 { + return articles, nil + } + err := r.articlePreloads(r.db.WithContext(ctx)).Where("portal_id = ? AND id IN ?", portalID, ids).Order("id ASC").Find(&articles).Error + return articles, err +} + +func (r *ArticleRepo) CategoryExistsForPortalAndLocale(ctx context.Context, portalID uint, categoryID uint, locale string) (bool, error) { + var count int64 + db := r.db.WithContext(ctx).Model(&model.Category{}).Where("portal_id = ? AND id = ?", portalID, categoryID) + if locale != "" { + db = db.Where("locale = ?", locale) + } + if err := db.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +func (r *ArticleRepo) FindTranslationsByRootIDsAndLocale(ctx context.Context, portalID uint, rootIDs []uint, locale string) ([]model.Article, error) { + var articles []model.Article + if len(rootIDs) == 0 || locale == "" { + return articles, nil + } + err := r.articlePreloads(r.db.WithContext(ctx)).Where("portal_id = ? AND associated_article_id IN ? AND locale = ?", portalID, rootIDs, locale).Order("id ASC").Find(&articles).Error + return articles, err +} + +func (r *ArticleRepo) FindTranslationByRootAndLocale(ctx context.Context, portalID uint, rootID uint, locale string) (*model.Article, error) { + var article model.Article + if err := r.articlePreloads(r.db.WithContext(ctx)).Where("portal_id = ? AND associated_article_id = ? AND locale = ?", portalID, rootID, locale).First(&article).Error; err != nil { + return nil, err + } + return &article, nil +} + func (r *ArticleRepo) CategoryExistsForPortal(ctx context.Context, portalID uint, categoryID uint) (bool, error) { var count int64 if err := r.db.WithContext(ctx).Model(&model.Category{}).Where("portal_id = ? AND id = ?", portalID, categoryID).Count(&count).Error; err != nil { diff --git a/internal/service/article_service.go b/internal/service/article_service.go index 6e23804f..b2364b15 100644 --- a/internal/service/article_service.go +++ b/internal/service/article_service.go @@ -3,20 +3,65 @@ package service import ( "context" "encoding/json" + "errors" "fmt" "regexp" "strings" "time" + "github.com/gochat/gochat/internal/llm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" + "github.com/gochat/gochat/internal/worker" applogger "github.com/gochat/gochat/pkg/logger" + "golang.org/x/text/language" + "golang.org/x/text/language/display" + "gorm.io/gorm" ) +const TaskTypeCaptainArticleTranslate = "captain:article_translate" + // ArticleService implements business logic for Article CRUD. type ArticleService struct { repo *repository.ArticleRepo searchIndexer SearchIndexer + worker *worker.WorkerPool + translator ArticleTranslationBackend +} + +type ArticleTranslationBackend interface { + TranslateArticleText(ctx context.Context, account *model.Account, text string, targetLanguage string, kind string) (string, error) +} + +type LLMArticleTranslationBackend struct { + provider llm.Provider +} + +func NewLLMArticleTranslationBackend(provider llm.Provider) *LLMArticleTranslationBackend { + return &LLMArticleTranslationBackend{provider: provider} +} + +func (b *LLMArticleTranslationBackend) TranslateArticleText(ctx context.Context, _ *model.Account, text string, targetLanguage string, kind string) (string, error) { + if b == nil || b.provider == nil { + return "", ErrArticleBulkTranslationWorkerUnavailable + } + kind = strings.ToLower(strings.TrimSpace(kind)) + if kind != "title" && kind != "content" { + return "", fmt.Errorf("invalid article translation type: %s", kind) + } + resp, err := b.provider.ChatCompletion(ctx, llm.ChatRequest{ + Messages: []llm.ChatMessage{ + {Role: "system", Content: articleTranslationSystemPrompt(kind, languageNameForLocale(targetLanguage))}, + {Role: "user", Content: text}, + }, + }) + if err != nil { + return "", fmt.Errorf("article translation llm: %w", err) + } + if resp == nil || len(resp.Choices) == 0 { + return "", nil + } + return strings.TrimSpace(resp.Choices[0].Message.Content), nil } func NewArticleService(repo *repository.ArticleRepo) *ArticleService { @@ -27,6 +72,15 @@ func (s *ArticleService) SetSearchIndexer(indexer SearchIndexer) { s.searchIndexer = indexer } +func (s *ArticleService) SetWorkerPool(wp *worker.WorkerPool) { + s.worker = wp + RegisterArticleTranslationJobs(wp, s) +} + +func (s *ArticleService) SetArticleTranslationBackend(backend ArticleTranslationBackend) { + s.translator = backend +} + func (s *ArticleService) indexArticle(ctx context.Context, article *model.Article) { if s.searchIndexer != nil { logSearchIndexError("article", article.ID, s.searchIndexer.IndexArticle(ctx, article)) @@ -463,6 +517,81 @@ type BulkActionsRequest struct { IDs []uint `json:"ids" validate:"required,min=1"` } +type BulkTranslateRequest struct { + IDs []uint `json:"ids"` + Locale string `json:"locale"` + CategoryID *uint `json:"category_id"` + Force bool `json:"force"` +} + +type ArticleTranslationDuplicate struct { + ID uint `json:"id"` + Title string `json:"title"` +} + +type ArticleTranslationConflictError struct { + Duplicates []ArticleTranslationDuplicate +} + +func (e *ArticleTranslationConflictError) Error() string { return "article translations already exist" } + +func (s *ArticleService) BulkTranslate(ctx context.Context, accountID uint, portal *model.Portal, userID uint, req BulkTranslateRequest) error { + if portal == nil { + return fmt.Errorf("portal not found") + } + var account model.Account + if err := s.repo.DB().WithContext(ctx).First(&account, accountID).Error; err != nil { + return fmt.Errorf("account not found: %w", err) + } + if !featureFlagStringEnabled(account.FeatureFlags, "captain_tasks") { + return ErrArticleBulkCaptainNotAvailable + } + if !portalAllowsLocale(portal, req.Locale) { + return ErrArticleBulkLocaleNotAvailable + } + if req.CategoryID != nil { + exists, err := s.repo.CategoryExistsForPortalAndLocale(ctx, portal.ID, *req.CategoryID, req.Locale) + if err != nil { + return fmt.Errorf("find category: %w", err) + } + if !exists { + return ErrArticleBulkCategoryNotFound + } + } + articles, err := s.repo.FindByPortalAndIDs(ctx, portal.ID, req.IDs) + if err != nil { + return fmt.Errorf("find articles: %w", err) + } + if len(articles) == 0 { + return ErrArticleBulkNoArticles + } + rootIDs := articleRootIDs(articles) + duplicates, err := s.repo.FindTranslationsByRootIDsAndLocale(ctx, portal.ID, rootIDs, req.Locale) + if err != nil { + return fmt.Errorf("find translations: %w", err) + } + if len(duplicates) > 0 && !req.Force { + return &ArticleTranslationConflictError{Duplicates: articleTranslationDuplicates(duplicates)} + } + if s.worker == nil { + return ErrArticleBulkTranslationWorkerUnavailable + } + for _, article := range articles { + payload := captainArticleTranslateJob{AccountID: accountID, ArticleID: article.ID, Locale: req.Locale, UserID: userID} + if req.CategoryID != nil { + payload.CategoryID = *req.CategoryID + } + if _, err := s.worker.Enqueue(ctx, TaskTypeCaptainArticleTranslate, payload, + worker.WithQueue("low"), + worker.WithMaxAttempts(3), + worker.WithIdempotencyKey(fmt.Sprintf("captain:article_translate:%d:%d:%s", accountID, article.ID, req.Locale)), + ); err != nil { + return fmt.Errorf("enqueue article translation: %w", err) + } + } + return nil +} + // BulkActions dispatches a bulk operation (publish/archive/delete) on the given article IDs. // Reference: Chatwoot articles_controller#bulk_actions func (s *ArticleService) BulkActions(ctx context.Context, req *BulkActionsRequest) error { @@ -479,11 +608,217 @@ func (s *ArticleService) BulkActions(ctx context.Context, req *BulkActionsReques } var ( - ErrArticleBulkNoArticles = fmt.Errorf("no articles found") - ErrArticleBulkInvalidStatus = fmt.Errorf("invalid status") - ErrArticleBulkCategoryNotFound = fmt.Errorf("category not found") + ErrArticleBulkNoArticles = fmt.Errorf("no articles found") + ErrArticleBulkInvalidStatus = fmt.Errorf("invalid status") + ErrArticleBulkCategoryNotFound = fmt.Errorf("category not found") + ErrArticleBulkCaptainNotAvailable = fmt.Errorf("captain not available") + ErrArticleBulkLocaleNotAvailable = fmt.Errorf("locale not available") + ErrArticleBulkTranslationWorkerUnavailable = fmt.Errorf("article translation worker unavailable") ) +type captainArticleTranslateJob struct { + AccountID uint `json:"account_id"` + ArticleID uint `json:"article_id"` + Locale string `json:"locale"` + CategoryID uint `json:"category_id,omitempty"` + UserID uint `json:"user_id"` +} + +func RegisterArticleTranslationJobs(wp *worker.WorkerPool, svc *ArticleService) { + if wp == nil || svc == nil { + return + } + wp.Register(TaskTypeCaptainArticleTranslate, svc.performArticleTranslateJob) +} + +func (s *ArticleService) performArticleTranslateJob(ctx context.Context, job *model.BackgroundJob) error { + var payload captainArticleTranslateJob + if len(job.Payload) > 0 { + if err := json.Unmarshal(job.Payload, &payload); err != nil { + return fmt.Errorf("unmarshal article translate job: %w", err) + } + } + if payload.AccountID == 0 || payload.ArticleID == 0 || payload.Locale == "" { + return fmt.Errorf("invalid article translate job payload: %#v", payload) + } + if s.translator == nil { + return ErrArticleBulkTranslationWorkerUnavailable + } + var account model.Account + if err := s.repo.DB().WithContext(ctx).First(&account, payload.AccountID).Error; err != nil { + return fmt.Errorf("account not found: %w", err) + } + source, err := s.repo.GetByAccountAndID(ctx, payload.AccountID, payload.ArticleID) + if err != nil { + return fmt.Errorf("source article not found: %w", err) + } + targetLanguage := languageNameForLocale(payload.Locale) + title, err := s.translator.TranslateArticleText(ctx, &account, source.Title, targetLanguage, "title") + if err != nil { + return fmt.Errorf("translate article title: %w", err) + } + content := source.Content + if strings.TrimSpace(source.Content) != "" { + content, err = s.translator.TranslateArticleText(ctx, &account, source.Content, targetLanguage, "content") + if err != nil { + return fmt.Errorf("translate article content: %w", err) + } + } + rootID := articleRootID(*source) + existing, err := s.repo.FindTranslationByRootAndLocale(ctx, source.PortalID, rootID, payload.Locale) + if err == nil && existing != nil { + existing.Title = title + existing.Content = content + existing.Description = source.Description + if err := s.repo.Update(ctx, existing); err != nil { + return fmt.Errorf("update article translation: %w", err) + } + s.indexArticle(ctx, existing) + return nil + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("find article translation: %w", err) + } + var categoryID *uint + if payload.CategoryID != 0 { + categoryID = &payload.CategoryID + } + translated := &model.Article{ + AccountID: source.AccountID, + PortalID: source.PortalID, + CategoryID: categoryID, + AuthorID: &payload.UserID, + Title: title, + Slug: articleSlug(fmt.Sprintf("%s-%s-%d", title, payload.Locale, source.ID)), + Description: source.Description, + Content: content, + Status: string(model.ArticleStatusDraft), + Locale: payload.Locale, + AssociatedArticleID: &rootID, + } + if err := s.repo.Create(ctx, translated); err != nil { + return fmt.Errorf("create article translation: %w", err) + } + s.indexArticle(ctx, translated) + return nil +} + +func articleRootID(article model.Article) uint { + if article.AssociatedArticleID != nil && *article.AssociatedArticleID != 0 { + return *article.AssociatedArticleID + } + return article.ID +} + +func articleRootIDs(articles []model.Article) []uint { + seen := map[uint]bool{} + ids := make([]uint, 0, len(articles)) + for _, article := range articles { + id := articleRootID(article) + if !seen[id] { + seen[id] = true + ids = append(ids, id) + } + } + return ids +} + +func articleTranslationDuplicates(articles []model.Article) []ArticleTranslationDuplicate { + duplicates := make([]ArticleTranslationDuplicate, 0, len(articles)) + for _, article := range articles { + duplicates = append(duplicates, ArticleTranslationDuplicate{ID: article.ID, Title: article.Title}) + } + return duplicates +} + +func articleTranslationSystemPrompt(kind string, targetLanguage string) string { + if kind == "title" { + return fmt.Sprintf(`You are a professional translator. +Translate the following text to %s. +Return only the translated text, no explanations or extra formatting.`, targetLanguage) + } + return fmt.Sprintf(`You are a professional translator. Translate the following content to %s. +The content is markdown that may contain embedded HTML blocks. +Rules: +- Translate ONLY the visible text content (headings, paragraphs, list items, table cells, etc.). +- Preserve ALL markdown formatting exactly: headings (#), bold (**), italic (*), links, lists, code blocks, blockquotes, tables, horizontal rules. +- Preserve ALL HTML tags, attributes, and structure exactly as they are. +- Do NOT translate or modify: URLs, image src/alt attributes, link href values, class names, IDs, data attributes, code blocks, or any HTML attribute values. +- Keep all image tags (both markdown ![](url) and HTML ), iframes, and embedded media completely unchanged. +- Preserve all line breaks, blank lines, and whitespace patterns. +- Return ONLY the translated content, no wrapping or explanations.`, targetLanguage) +} + +func languageNameForLocale(localeCode string) string { + localeCode = strings.TrimSpace(localeCode) + if localeCode == "" { + return localeCode + } + if name, ok := articleTranslationLanguageNames[localeCode]; ok { + return name + } + normalized := strings.ReplaceAll(localeCode, "_", "-") + if tag, err := language.Parse(normalized); err == nil { + name := display.English.Tags().Name(tag) + if name != "" { + return name + } + } + return localeCode +} + +var articleTranslationLanguageNames = map[string]string{ + "ar": "Arabic", + "de": "German", + "en": "English", + "es": "Spanish", + "fr": "French", + "hi": "Hindi", + "id": "Indonesian", + "it": "Italian", + "ja": "Japanese", + "ko": "Korean", + "nl": "Dutch", + "pl": "Polish", + "pt": "Portuguese", + "pt_BR": "Portuguese (Brazil)", + "ru": "Russian", + "th": "Thai", + "tr": "Turkish", + "vi": "Vietnamese", + "zh": "Chinese", + "zh_CN": "Chinese (China)", + "zh_TW": "Chinese (Taiwan)", +} + +func portalAllowsLocale(portal *model.Portal, locale string) bool { + if portal == nil || strings.TrimSpace(locale) == "" { + return false + } + var config map[string]any + if len(portal.PortalConfiguration) > 0 { + _ = json.Unmarshal(portal.PortalConfiguration, &config) + } + if raw, ok := config["allowed_locales"]; ok { + switch values := raw.(type) { + case []any: + for _, value := range values { + if fmt.Sprint(value) == locale { + return true + } + } + case []string: + for _, value := range values { + if value == locale { + return true + } + } + } + return false + } + return portal.Locale == locale +} + func validateArticleBulkStatus(status string) error { validStatuses := map[model.ArticleStatus]bool{ model.ArticleStatusDraft: true, diff --git a/internal/service/article_service_test.go b/internal/service/article_service_test.go index d2f1bfdd..f795d5d6 100644 --- a/internal/service/article_service_test.go +++ b/internal/service/article_service_test.go @@ -2,15 +2,41 @@ package service import ( "context" + "fmt" "testing" + "github.com/gochat/gochat/internal/llm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" + "github.com/gochat/gochat/internal/worker" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" ) +type fakeArticleTranslator struct{} + +func (fakeArticleTranslator) TranslateArticleText(_ context.Context, _ *model.Account, text string, targetLanguage string, kind string) (string, error) { + return fmt.Sprintf("%s[%s:%s]", text, targetLanguage, kind), nil +} + +type recordingArticleLLM struct { + requests []llm.ChatRequest +} + +func (m *recordingArticleLLM) ChatCompletion(_ context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) { + m.requests = append(m.requests, req) + return &llm.ChatResponse{Choices: []llm.ChatChoice{{Message: llm.ChatMessage{Content: " Texte traduit "}}}}, nil +} + +func (m *recordingArticleLLM) CreateEmbedding(context.Context, llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) { + return nil, fmt.Errorf("not used") +} + +func (m *recordingArticleLLM) ChatCompletionStream(context.Context, llm.ChatRequest, func(llm.StreamChunk) error) error { + return fmt.Errorf("not used") +} + // ========== Article test helpers ========== // setupArticleService creates ArticleRepo + ArticleService test instances. @@ -137,4 +163,94 @@ func TestArticleService_BulkDelete_Success(t *testing.T) { var found model.Article assert.ErrorIs(t, db.First(&found, a1.ID).Error, gorm.ErrRecordNotFound) assert.ErrorIs(t, db.First(&found, a2.ID).Error, gorm.ErrRecordNotFound) -} \ No newline at end of file +} + +func TestArticleService_BulkTranslate_QueuesCaptainJobsAndDetectsDuplicates(t *testing.T) { + db, _, svc := setupArticleService(t) + wp := worker.NewWorkerPool(db) + svc.SetWorkerPool(wp) + account := createTestAccount(t, db) + account.FeatureFlags = `{"captain_tasks":true}` + require.NoError(t, db.Save(account).Error) + portal := &model.Portal{AccountID: account.ID, Name: "Translate Portal", Slug: "translate-service", Locale: "en", PortalConfiguration: []byte(`{"allowed_locales":["en","fr"]}`)} + require.NoError(t, db.Create(portal).Error) + article := &model.Article{AccountID: account.ID, PortalID: portal.ID, Title: "Shipping", Slug: "shipping-service", Content: "Ship fast", Status: "published", Locale: "en"} + require.NoError(t, db.Create(article).Error) + duplicate := &model.Article{AccountID: account.ID, PortalID: portal.ID, Title: "Expedition", Slug: "expedition-service", Status: "draft", Locale: "fr", AssociatedArticleID: &article.ID} + require.NoError(t, db.Create(duplicate).Error) + + err := svc.BulkTranslate(context.Background(), account.ID, portal, 42, BulkTranslateRequest{IDs: []uint{article.ID}, Locale: "fr"}) + var conflict *ArticleTranslationConflictError + require.ErrorAs(t, err, &conflict) + require.Len(t, conflict.Duplicates, 1) + assert.Equal(t, duplicate.ID, conflict.Duplicates[0].ID) + + err = svc.BulkTranslate(context.Background(), account.ID, portal, 42, BulkTranslateRequest{IDs: []uint{article.ID}, Locale: "fr", Force: true}) + require.NoError(t, err) + var count int64 + require.NoError(t, db.Model(&model.BackgroundJob{}).Where("job_type = ? AND queue = ?", TaskTypeCaptainArticleTranslate, "low").Count(&count).Error) + assert.Equal(t, int64(1), count) +} + +func TestArticleService_ArticleTranslateJobCreatesAndUpdatesTranslations(t *testing.T) { + db, _, svc := setupArticleService(t) + wp := worker.NewWorkerPool(db) + svc.SetWorkerPool(wp) + svc.SetArticleTranslationBackend(fakeArticleTranslator{}) + account := createTestAccount(t, db) + portal := &model.Portal{AccountID: account.ID, Name: "Translate Job Portal", Slug: "translate-job-service", Locale: "en", PortalConfiguration: []byte(`{"allowed_locales":["en","fr"]}`)} + require.NoError(t, db.Create(portal).Error) + category := &model.Category{AccountID: account.ID, PortalID: portal.ID, Name: "Francais", Slug: "fr-service", Locale: "fr"} + require.NoError(t, db.Create(category).Error) + article := &model.Article{AccountID: account.ID, PortalID: portal.ID, Title: "Shipping", Slug: "shipping-job-service", Description: "Delivery", Content: "Ship fast", Status: "published", Locale: "en"} + require.NoError(t, db.Create(article).Error) + + _, err := wp.Enqueue(context.Background(), TaskTypeCaptainArticleTranslate, captainArticleTranslateJob{AccountID: account.ID, ArticleID: article.ID, Locale: "fr", CategoryID: category.ID, UserID: 7}, worker.WithQueue("low")) + require.NoError(t, err) + processed, err := wp.ProcessOne(context.Background()) + require.NoError(t, err) + require.True(t, processed) + + var translated model.Article + require.NoError(t, db.Where("portal_id = ? AND associated_article_id = ? AND locale = ?", portal.ID, article.ID, "fr").First(&translated).Error) + assert.Equal(t, "Shipping[French:title]", translated.Title) + assert.Equal(t, "Ship fast[French:content]", translated.Content) + assert.Equal(t, "Delivery", translated.Description) + require.NotNil(t, translated.CategoryID) + assert.Equal(t, category.ID, *translated.CategoryID) + assert.Equal(t, string(model.ArticleStatusDraft), translated.Status) + + article.Title = "Updated Shipping" + article.Content = "Ship faster" + require.NoError(t, db.Save(article).Error) + _, err = wp.Enqueue(context.Background(), TaskTypeCaptainArticleTranslate, captainArticleTranslateJob{AccountID: account.ID, ArticleID: article.ID, Locale: "fr", CategoryID: category.ID, UserID: 7}, worker.WithQueue("low")) + require.NoError(t, err) + processed, err = wp.ProcessOne(context.Background()) + require.NoError(t, err) + require.True(t, processed) + + var translations int64 + require.NoError(t, db.Model(&model.Article{}).Where("portal_id = ? AND associated_article_id = ? AND locale = ?", portal.ID, article.ID, "fr").Count(&translations).Error) + assert.Equal(t, int64(1), translations) + require.NoError(t, db.First(&translated, translated.ID).Error) + assert.Equal(t, "Updated Shipping[French:title]", translated.Title) + assert.Equal(t, "Ship faster[French:content]", translated.Content) +} + +func TestLLMArticleTranslationBackend_UsesChatwootPromptsAndLanguageNames(t *testing.T) { + provider := &recordingArticleLLM{} + backend := NewLLMArticleTranslationBackend(provider) + + translated, err := backend.TranslateArticleText(context.Background(), &model.Account{}, "# Hello", "fr", "content") + require.NoError(t, err) + assert.Equal(t, "Texte traduit", translated) + require.Len(t, provider.requests, 1) + req := provider.requests[0] + require.Len(t, req.Messages, 2) + assert.Contains(t, req.Messages[0].Content, "Translate the following content to French") + assert.Contains(t, req.Messages[0].Content, "Preserve ALL markdown formatting exactly") + assert.Equal(t, "# Hello", req.Messages[1].Content) + + _, err = backend.TranslateArticleText(context.Background(), &model.Account{}, "Hello", "fr", "summary") + require.Error(t, err) +}