feat(help-center): queue article translations

This commit is contained in:
2026-06-06 05:15:48 +08:00
parent 76604353f8
commit 5a3da22a21
7 changed files with 604 additions and 10 deletions
+8 -5
View File
@@ -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.
+2
View File
@@ -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)
+23 -1
View File
@@ -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()
}
@@ -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)
+48
View File
@@ -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 {
+338 -3
View File
@@ -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 <img>), 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,
+117 -1
View File
@@ -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)
}
}
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)
}