From 9db133697e971a8db10070933c35e7e2a142c9a0 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 22:40:35 +0800 Subject: [PATCH] feat(inboxes): queue template sync jobs --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 12 +-- internal/app/bootstrap.go | 2 + internal/handler/api/v1/inbox_handler.go | 8 +- internal/handler/api/v1/inbox_handler_test.go | 43 ++++++++++ internal/service/inbox_service.go | 83 +++++++++++++++---- internal/service/inbox_service_test.go | 63 ++++++++++---- 6 files changed, 168 insertions(+), 43 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 7815d9f8..f6d87be0 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -16,10 +16,10 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc ## Current Baseline -- Current tracking checkpoint: 2026-06-05 after `3fc275b feat(notifications): align chatwoot notification payloads`, with this implementation checkpoint prepared as `feat(inboxes): align whatsapp health endpoints`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(inboxes): align whatsapp health endpoints`. +- Current tracking checkpoint: 2026-06-05 after `af9e1eb feat(inboxes): align whatsapp health endpoints`, with this implementation checkpoint prepared as `feat(inboxes): queue template sync jobs`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(inboxes): queue template sync jobs`. - Latest documentation-only checkpoint: `2923aae docs: land parity execution tracker`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`. -- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, v2 history handling, and no-LLM fallback; B11.3c adds the fakeable Captain document sync backend gate with disabled, failed, and fake-success states; B11.3d aligns Captain task request/response payloads, no-provider disabled states, follow-up context, suggestion persistence, and Copilot message tool-call key validation; B11.3e aligns Captain stream DTOs/disabled SSE fallbacks and Copilot push-event payload shapes; B12.1 adds the reusable GoChat server/seed entrypoint plus a Meilisearch-first reused Chatwoot frontend smoke harness and report; B12.2a adds API smoke assertions for auth/profile, inbox, conversation/messages, contact/company, widget config/message, and public CSAT; B12.2b adds a zero-dependency Chrome DevTools browser smoke that loads the reused Chatwoot login and dashboard entrypoints through Vite and checks browser auth/dashboard API requests; B12.3a adds enterprise API smoke assertions for SLA reports/download, CSAT reports/download, automation/macros, audit/custom roles, capacity, Captain, and Copilot; B12.3b adds reused-frontend enterprise browser route navigation for SLA, CSAT, automation, macros, audit logs, custom roles, capacity, Captain, and Copilot request coverage; P5.1 adds the PostgreSQL-backed durable `background_jobs` model/migration plus WorkerPool enqueue, schedule, retry/backoff, dead-letter, idempotency, stale-lock recovery, and focused tests; P5.2 wires `channel.Dispatcher` and `dispatch.EventDispatcher` async paths into durable event jobs with worker replay tests; P5.3 queues Meilisearch write-side index/delete jobs for conversations, messages, contacts, companies, and articles while keeping search reads Meilisearch-first; P5.4 queues automation webhook and email transcript side effects as durable jobs while preserving fakeable delivery boundaries; P5.5 queues Chatwoot-style macro execute fan-out through durable `automation:macro_execution` jobs; P5.6 queues resolve-triggered CSAT survey sends and WhatsApp/Twilio CSAT template creation through durable jobs; P5.7 queues Chatwoot enterprise SLA account scans and applied-SLA evaluation jobs through the durable worker; P5.8 queues Chatwoot-style contact export artifact generation through durable `contact:export` jobs; P5.9 queues normalized provider inbound message persistence/dispatch through durable `webhook:incoming_message_persist` jobs; P5.10 queues Chatwoot `SendReplyJob`-style outbound message delivery through durable `message:send_reply` jobs and provider delivery-status/read-receipt updates through durable webhook status jobs; P5.11 queues Captain document sync, crawl/parser, schedule-sync, response-builder, embedding-update, Copilot response, and Captain conversation response-builder work through durable jobs; P5.12 queues scheduled item fan-out, one-off campaigns, snoozed conversation reopening, account auto-resolution, widget/public message status updates, and account conversation bulk actions through durable jobs; P5.13a replaces the live report, bot report, conversation summary, inbox-label matrix, first-response distribution, and outgoing-message placeholder responses with persisted conversation/message/reporting-event aggregations; P5.13b routes `GET /reports` to Chatwoot-style metric timeseries, adds lazy rollup freshness/idempotency, registers durable `reporting:rollup_day` jobs, and makes rollup replacement hard-delete soft-deleted rows before recompute; B9.3 verifies the current reference exposes no delayed automation action params, preserves scheduled-item coverage under P5.12, and completes remaining automation action side effects by queueing `send_email_to_team` plus applying enterprise `add_sla`; Phase 2/3 drift audit now aligns account notifications with Chatwoot `NotificationFinder` list envelopes, `includes` filters, unread counts, primary-actor `read_all`, raw action responses, and `destroy_all` read/all semantics; P6.5 now aligns WhatsApp Cloud inbox health/register-webhook endpoints with Chatwoot raw payloads, Cloud-only guards, Graph health fields, default callback URL construction, and no-body register behavior. Next active implementation slice is continued Phase 6 placeholder audit, followed by further Phase 2/3 drift from smoke/reference evidence. +- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, v2 history handling, and no-LLM fallback; B11.3c adds the fakeable Captain document sync backend gate with disabled, failed, and fake-success states; B11.3d aligns Captain task request/response payloads, no-provider disabled states, follow-up context, suggestion persistence, and Copilot message tool-call key validation; B11.3e aligns Captain stream DTOs/disabled SSE fallbacks and Copilot push-event payload shapes; B12.1 adds the reusable GoChat server/seed entrypoint plus a Meilisearch-first reused Chatwoot frontend smoke harness and report; B12.2a adds API smoke assertions for auth/profile, inbox, conversation/messages, contact/company, widget config/message, and public CSAT; B12.2b adds a zero-dependency Chrome DevTools browser smoke that loads the reused Chatwoot login and dashboard entrypoints through Vite and checks browser auth/dashboard API requests; B12.3a adds enterprise API smoke assertions for SLA reports/download, CSAT reports/download, automation/macros, audit/custom roles, capacity, Captain, and Copilot; B12.3b adds reused-frontend enterprise browser route navigation for SLA, CSAT, automation, macros, audit logs, custom roles, capacity, Captain, and Copilot request coverage; P5.1 adds the PostgreSQL-backed durable `background_jobs` model/migration plus WorkerPool enqueue, schedule, retry/backoff, dead-letter, idempotency, stale-lock recovery, and focused tests; P5.2 wires `channel.Dispatcher` and `dispatch.EventDispatcher` async paths into durable event jobs with worker replay tests; P5.3 queues Meilisearch write-side index/delete jobs for conversations, messages, contacts, companies, and articles while keeping search reads Meilisearch-first; P5.4 queues automation webhook and email transcript side effects as durable jobs while preserving fakeable delivery boundaries; P5.5 queues Chatwoot-style macro execute fan-out through durable `automation:macro_execution` jobs; P5.6 queues resolve-triggered CSAT survey sends and WhatsApp/Twilio CSAT template creation through durable jobs; P5.7 queues Chatwoot enterprise SLA account scans and applied-SLA evaluation jobs through the durable worker; P5.8 queues Chatwoot-style contact export artifact generation through durable `contact:export` jobs; P5.9 queues normalized provider inbound message persistence/dispatch through durable `webhook:incoming_message_persist` jobs; P5.10 queues Chatwoot `SendReplyJob`-style outbound message delivery through durable `message:send_reply` jobs and provider delivery-status/read-receipt updates through durable webhook status jobs; P5.11 queues Captain document sync, crawl/parser, schedule-sync, response-builder, embedding-update, Copilot response, and Captain conversation response-builder work through durable jobs; P5.12 queues scheduled item fan-out, one-off campaigns, snoozed conversation reopening, account auto-resolution, widget/public message status updates, and account conversation bulk actions through durable jobs; P5.13a replaces the live report, bot report, conversation summary, inbox-label matrix, first-response distribution, and outgoing-message placeholder responses with persisted conversation/message/reporting-event aggregations; P5.13b routes `GET /reports` to Chatwoot-style metric timeseries, adds lazy rollup freshness/idempotency, registers durable `reporting:rollup_day` jobs, and makes rollup replacement hard-delete soft-deleted rows before recompute; B9.3 verifies the current reference exposes no delayed automation action params, preserves scheduled-item coverage under P5.12, and completes remaining automation action side effects by queueing `send_email_to_team` plus applying enterprise `add_sla`; Phase 2/3 drift audit now aligns account notifications with Chatwoot `NotificationFinder` list envelopes, `includes` filters, unread counts, primary-actor `read_all`, raw action responses, and `destroy_all` read/all semantics; P6.5 now aligns WhatsApp Cloud inbox health/register-webhook endpoints with Chatwoot raw payloads, Cloud-only guards, Graph health fields, default callback URL construction, and no-body register behavior; P6.5 also aligns `sync_templates` with Chatwoot's async low-priority template sync job and `{ message: "Template sync initiated successfully" }` response. Next active implementation slice is continued Phase 6 placeholder audit, followed by further Phase 2/3 drift from smoke/reference evidence. - `go test ./...` passes. - Route dump succeeds with `TOTAL: 833` after adding `GET /api/v1/accounts/:account_id/reports` for the Chatwoot reports index path. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -88,7 +88,7 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | | 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current critical set; notification list/action serializers now match Chatwoot's frontend contract; broader serializer fixtures remain partial. | Expand route/serializer fixtures when smoke or reference inspection exposes drift. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. | -| 2 | Phase 6 placeholder audit | Widget/public/webhook critical placeholders are burned down; inbox WhatsApp health/register-webhook drift is closed; account/contact/conversation/message/inbox audit remains broad. | Continue fresh placeholder audit and assign every frontend-reachable stub to a tracked owner. | `rg` placeholder audit is recorded and no reused-frontend blocker is ownerless. | +| 2 | Phase 6 placeholder audit | Widget/public/webhook critical placeholders are burned down; inbox WhatsApp health/register-webhook and sync-template drift are closed; account/contact/conversation/message/inbox audit remains broad. | Continue fresh placeholder audit and assign every frontend-reachable stub to a tracked owner. | `rg` placeholder audit is recorded and no reused-frontend blocker is ownerless. | | 3 | B12 optional live smoke | API/browser/enterprise smoke commands are checked in; live runs need PostgreSQL, Redis, Meilisearch, Vite, and Chrome. | Run full live smoke when environment is available and map failures to the board. | `docs/parity/frontend_smoke_report.md` records pass/fail and linked owners. | | 4 | B9.3 delayed automation actions | Current reference exposes no delayed automation action params; scheduled-item work is already P5.12; `send_email_to_team` is durable and `add_sla` mutates conversation/applied SLA state. | Keep automation drift closed if future reference/smoke exposes delayed params or unsupported action shapes. | Automation worker/action fixtures verify queued team email replay, retry visibility through worker jobs, and SLA action idempotency. | | 5 | P5.13 reports/analytics | P5.13a derives visible report aggregates from persisted rows; P5.13b adds lazy rollup freshness, durable day rollup jobs, and `/reports` metric timeseries. | Keep report drift closed as frontend smoke or reference inspection exposes additional metrics. | Report fixtures verify timeseries values, cache/freshness behavior, and no hidden placeholder JSON. | @@ -139,6 +139,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(inboxes): queue template sync jobs` | Advances P6.5 inbox API parity by aligning `POST /inboxes/:id/sync_templates` with `Api::V1::Accounts::Concerns::WhatsappHealthManagement#sync_templates`. The endpoint no longer blocks on live provider template fetches or returns a local `templates` envelope; it validates WhatsApp inboxes, queues durable low-priority `inbox:sync_templates` jobs, replays the existing WhatsApp template fetch/update path in the worker, and returns Chatwoot `{ message: "Template sync initiated successfully" }`. | `go test ./internal/service -run 'InboxService.*SyncTemplates\|InboxService.*Health\|InboxService.*RegisterWebhook' -count=1`; `go test ./internal/handler/api/v1 -run InboxSyncTemplates -count=1`; `go test ./internal/app -count=1`; `go test ./...`; `git diff --check`. | P6.5 remains Doing for broader inbox/channel settings parity; continue Phase 6 placeholder audit across account/contact/conversation/message/inbox surfaces. | | `feat(inboxes): align whatsapp health endpoints` | Advances P6.5 inbox API parity by replacing the generic inbox health result with Chatwoot's WhatsApp Cloud-only `health` contract and aligning `register_webhook` with the reference concern. Health now returns raw Graph phone-number fields plus `expected_webhook_url` and `business_id`; non-WhatsApp/non-Cloud inboxes return the Chatwoot `400` error; register webhook accepts an empty body, builds the default callback from `FRONTEND_URL`, and returns `Webhook registered successfully`. WhatsApp Graph health reads now use `WHATSAPP_API_VERSION` default `v22.0`. | `go test ./internal/channel/whatsapp -count=1`; `go test ./internal/service -run InboxService -count=1`; `go test ./internal/handler/api/v1 -run Inbox -count=1`; `go test ./...`; `git diff --check`. | P6.5 remains Doing for broader inbox/channel settings parity; continue Phase 6 placeholder audit across account/contact/conversation/message/inbox surfaces. | | `feat(notifications): align chatwoot notification payloads` | Advances Phase 2/3 drift by aligning the account notifications API with `reference/chatwoot/app/controllers/api/v1/accounts/notifications_controller.rb`, `NotificationFinder`, `index.json.jbuilder`, and the reused frontend `notifications.js` client. Notification index now returns Chatwoot `{ data: { meta, payload } }`, supports `includes[]=read/snoozed`, fixed unread/count metadata, raw notification action responses, primary-actor-scoped `read_all`, raw unread count, and `destroy_all` read/all semantics from query or JSON body. | `go test ./internal/handler/api/v1 -run Notification -count=1`; `go test ./internal/service -run Notification -count=1`; `go test ./internal/handler/api/v1 ./internal/service -count=1`; `go test ./...`; `git diff --check`. | Notification serializer/action drift moves to Review; continue Phase 6 placeholder audit, then further Phase 2/3 drift from smoke/reference evidence. | | `feat(automation): close delayed action parity` | Closes B9.3 for the current reference. `reference/chatwoot` automation models, action service, enterprise action service, listener, and dashboard action components expose no configurable delayed action params, so no separate delayed automation scheduler is required beyond P5.12 scheduled items. The checkpoint completes remaining automation action side effects: `send_email_to_team` now queues durable `automation:team_email_delivery` jobs and replays through the fakeable mailer boundary, while enterprise `add_sla` attaches an account-scoped SLA policy and creates idempotent AppliedSLA targets. The automation serializer now preserves Chatwoot's object-shaped `send_email_to_team` params. | `go test ./internal/automation -run 'ActionService\|AutomationRuleService_MatchAndExecute\|ExecutionLogService' -count=1`; `go test ./internal/handler/api/v1 -run AutomationRule -count=1`; `go test ./internal/automation ./internal/handler/api/v1 ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`. | B9.3 and Phase 5 move to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, and optional B12 live smoke. | @@ -1963,7 +1964,7 @@ Tracking table: | P6.2 | Contact APIs | `docs/ROUTE_GAP_ANALYSIS.md`, contact handlers/services | Finish merge, import/export/data-import, notes serializer depth, and Meilisearch-backed CRM search. | Doing | | P6.3 | Conversation APIs | `docs/ROUTE_GAP_ANALYSIS.md`, conversation handlers/services | Implement frontend-critical filters, assignment, status, snooze, merge, bulk actions. | Todo | | P6.4 | Message APIs | `docs/ROUTE_GAP_ANALYSIS.md`, message handlers/services | Implement create/list/delete, private notes, attachments, source attribution, events. | Todo | -| P6.5 | Inbox APIs | `docs/ROUTE_GAP_ANALYSIS.md`, inbox handlers/services | Implement CRUD, assignable agents, avatar, campaigns, channel settings, reset secret. WhatsApp Cloud health/register-webhook member actions now match the reference concern. | Doing | +| P6.5 | Inbox APIs | `docs/ROUTE_GAP_ANALYSIS.md`, inbox handlers/services | Implement CRUD, assignable agents, avatar, campaigns, channel settings, reset secret. WhatsApp Cloud health/register-webhook/sync-template member actions now match the reference concern. | Doing | | P6.6 | Widget/public APIs | `docs/ROUTE_GAP_ANALYSIS.md`, widget/channel provider code, `chatwootParityStub` routes | Widget/public frontend-critical route behavior is handler-backed, including public inbox flow, direct uploads/attachments, and public CSAT survey submission. | Done | | P6.7 | Webhook ingress | `internal/router/router.go`, `internal/handler/webhook/*`, channel providers | Replace generic placeholder with provider-specific verified ingestion and dispatch. | Done | @@ -2050,6 +2051,7 @@ Verification milestone gates: ## Progress Log +- 2026-06-05: P6.5 inbox sync-template checkpoint prepared as `feat(inboxes): queue template sync jobs`; audited Chatwoot `WhatsappHealthManagement#sync_templates`, `Channels::Whatsapp::TemplatesSyncJob`, and the reused frontend inbox API/store action. `POST /inboxes/:id/sync_templates` now validates WhatsApp inboxes, enqueues durable low-priority `inbox:sync_templates` jobs instead of synchronously fetching provider templates, replays the existing WhatsApp template fetch/update path in the worker, and returns Chatwoot `{ message: "Template sync initiated successfully" }` without a local `templates` envelope. Focused inbox service/handler/app tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder audit next. - 2026-06-05: P6.5 inbox placeholder/drift checkpoint prepared as `feat(inboxes): align whatsapp health endpoints`; audited `reference/chatwoot/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb`, `Whatsapp::HealthService`, `Whatsapp::WebhookSetupService`, and the reused frontend inbox health client/component. `GET /inboxes/:id/health` now returns the raw WhatsApp Cloud Graph health payload with `expected_webhook_url` and `business_id`, rejects non-WhatsApp/non-Cloud inboxes with Chatwoot's `400` error, and drops the generic `healthy/status/details` placeholder shape. `POST /inboxes/:id/register_webhook` now accepts an empty body, builds the default callback from `FRONTEND_URL`, returns `Webhook registered successfully`, and uses the same Cloud-only guard. Focused WhatsApp, inbox service, inbox handler tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder audit next. - 2026-06-05: Phase 2/3 notification drift checkpoint prepared as `feat(notifications): align chatwoot notification payloads`; audited `reference/chatwoot` notifications controller, `NotificationFinder`, index Jbuilder, and dashboard notifications API/store consumers. Account notifications index now returns Chatwoot `{ data: { meta, payload } }`, filters read/snoozed items with `includes[]=read/snoozed`, reports `count/current_page/unread_count`, serializes frontend-required notification fields, marks all notifications or a primary actor as read, returns raw update/unread/snooze notification payloads, returns unread count as a raw number, and honors `destroy_all` `type=read` from query or JSON body. Focused notification handler/service tests, combined handler/service tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder audit next. - 2026-06-05: B9.3 delayed automation action checkpoint prepared as `feat(automation): close delayed action parity`; audited current `reference/chatwoot` automation rule model, listener, action services, enterprise action service, and dashboard action inputs and found no configurable delayed action params. Scheduled root/background work remains covered by P5.12, while this checkpoint closes remaining automation action side effects: `send_email_to_team` now preserves Chatwoot object-shaped params and queues durable `automation:team_email_delivery` jobs through the fakeable mailer boundary, and enterprise `add_sla` attaches account-scoped SLA policies plus idempotent AppliedSLA target rows. Focused automation action/execution tests, AutomationRule handler tests, automation/handler/worker/app package tests, full `go test ./...`, and `git diff --check` passed. B9.3 and Phase 5 move to Review; next slice is Phase 2/3 drift audit. diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 6dd002aa..5b2aea90 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -510,6 +510,8 @@ func Bootstrap(env string) (*App, error) { conversationParticipantService := service.NewConversationParticipantService(conversationParticipantRepo, conversationRepo) draftMessageService := service.NewDraftMessageService(draftMessageRepo, conversationRepo) inboxService := service.NewInboxService(inboxRepo, agentBotInboxRepo, agentBotRepo, campaignRepo, webhookSubRepo, waService, waRepo) + inboxService.SetWorkerPool(workerPool) + service.RegisterInboxTemplateSyncJobs(workerPool, inboxService) igRepo := repository.NewChannelInstagramRepo(db) igService := service.NewChannelInstagramService(igRepo, igProvider) fbChannelRepo := repository.NewChannelFacebookRepo(db) diff --git a/internal/handler/api/v1/inbox_handler.go b/internal/handler/api/v1/inbox_handler.go index 24fbfc6e..3a4a00a7 100644 --- a/internal/handler/api/v1/inbox_handler.go +++ b/internal/handler/api/v1/inbox_handler.go @@ -656,13 +656,17 @@ func (h *InboxHandler) SyncTemplates(c *gin.Context) { return } - templates, svcErr := h.svc.SyncTemplates(c.Request.Context(), accountID, inboxID) + svcErr := h.svc.SyncTemplates(c.Request.Context(), accountID, inboxID) if svcErr != nil { + if errors.Is(svcErr, service.ErrInboxTemplateSyncWhatsAppOnly) { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": service.InboxTemplateSyncWhatsAppOnlyMessage}) + return + } handleServiceError(c, svcErr) return } - c.JSON(http.StatusOK, gin.H{"templates": templates}) + c.JSON(http.StatusOK, gin.H{"message": service.InboxTemplateSyncInitiatedMessage}) } // RegisterWebhook registers a webhook URL with the channel provider for an inbox. diff --git a/internal/handler/api/v1/inbox_handler_test.go b/internal/handler/api/v1/inbox_handler_test.go index 5213110d..de187b96 100644 --- a/internal/handler/api/v1/inbox_handler_test.go +++ b/internal/handler/api/v1/inbox_handler_test.go @@ -1,6 +1,7 @@ package v1 import ( + "bytes" "encoding/json" "net/http" "net/http/httptest" @@ -8,8 +9,17 @@ import ( "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + whatsappchannel "github.com/gochat/gochat/internal/channel/whatsapp" + "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" + "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" + "github.com/gochat/gochat/internal/worker" ) // setupInboxMemberActionRouter creates a test router with inbox member-action routes. @@ -123,6 +133,39 @@ func TestInboxSyncTemplates_BadInboxID(t *testing.T) { assert.Contains(t, parseJSONError(w.Body.Bytes()), "invalid inbox id") } +func TestInboxSyncTemplates_ChatwootQueuedResponse(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:inbox_sync_templates_handler?mode=memory&cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + t.Cleanup(func() { + sqlDB, dbErr := db.DB() + if dbErr == nil { + _ = sqlDB.Close() + } + }) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &channelmodel.ChannelWhatsApp{}, &model.BackgroundJob{})) + account := &model.Account{Name: "Sync Templates", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "WhatsApp", ChannelType: "whatsapp", ChannelID: 1} + require.NoError(t, db.Create(inbox).Error) + channel := &channelmodel.ChannelWhatsApp{AccountID: account.ID, InboxID: inbox.ID, PhoneNumber: "+1555010000", PhoneNumberID: "phone-1", BusinessAccountID: "waba-1", AccessToken: "token", Provider: "whatsapp_cloud"} + require.NoError(t, db.Create(channel).Error) + + wp := worker.NewWorkerPool(db) + svc := service.NewInboxService(repository.NewInboxRepo(db), nil, nil, nil, nil, nil, whatsappchannel.NewRepository(db)) + svc.SetWorkerPool(wp) + handler := NewInboxHandler(svc) + router := setupInboxMemberActionRouter(handler) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/v1/accounts/1/inboxes/1/sync_templates", bytes.NewReader(nil)) + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, service.InboxTemplateSyncInitiatedMessage, body["message"]) + assert.NotContains(t, body, "templates") +} + // ======================================== // RegisterWebhook — param validation tests // ======================================== diff --git a/internal/service/inbox_service.go b/internal/service/inbox_service.go index a9b50a60..c94c76e6 100644 --- a/internal/service/inbox_service.go +++ b/internal/service/inbox_service.go @@ -15,15 +15,20 @@ import ( "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/repository" + "github.com/gochat/gochat/internal/worker" applogger "github.com/gochat/gochat/pkg/logger" pkgvalidator "github.com/gochat/gochat/pkg/validator" ) const InboxLimitExceededMessage = "Account limit exceeded. Upgrade to a higher plan" const InboxHealthWhatsAppCloudOnlyMessage = "Health data only available for WhatsApp Cloud API channels" +const InboxTemplateSyncInitiatedMessage = "Template sync initiated successfully" +const InboxTemplateSyncWhatsAppOnlyMessage = "Template sync is only available for WhatsApp channels" +const TaskTypeInboxSyncTemplates = "inbox:sync_templates" var ErrInboxLimitExceeded = errors.New(InboxLimitExceededMessage) var ErrInboxHealthWhatsAppCloudOnly = errors.New(InboxHealthWhatsAppCloudOnlyMessage) +var ErrInboxTemplateSyncWhatsAppOnly = errors.New(InboxTemplateSyncWhatsAppOnlyMessage) type WhatsAppChannelService interface { FetchMessageTemplates(ctx context.Context, channel *channelmodel.ChannelWhatsApp) ([]interface{}, error) @@ -41,6 +46,7 @@ type InboxService struct { webhookSubRepo *repository.WebhookSubscriptionRepo whatsappService WhatsAppChannelService whatsappRepo *whatsapp.Repository + worker *worker.WorkerPool } // NewInboxService creates a new Inbox service. @@ -69,6 +75,10 @@ func (s *InboxService) Ready() bool { return s != nil && s.repo != nil } +func (s *InboxService) SetWorkerPool(wp *worker.WorkerPool) { + s.worker = wp +} + // ListByAccount retrieves all inboxes for an account. func (s *InboxService) ListByAccount(ctx context.Context, accountID uint, offset, limit int) ([]model.Inbox, int64, error) { return s.repo.FindByAccount(ctx, accountID, offset, limit) @@ -1625,36 +1635,28 @@ func (s *InboxService) Health(ctx context.Context, accountID, inboxID uint) (map return s.fetchWhatsAppHealthStatus(ctx, waChannel) } -// SyncTemplates syncs message templates for an inbox's channel (currently WhatsApp only). -// For WhatsApp, this calls the WhatsApp Business API to fetch available templates -// and stores them in the channel's message_templates field. +// SyncTemplates queues message template sync for an inbox's WhatsApp channel. // Reference: Chatwoot InboxesController#sync_templates (POST member action, WhatsApp only) -func (s *InboxService) SyncTemplates(ctx context.Context, accountID, inboxID uint) ([]interface{}, error) { +func (s *InboxService) SyncTemplates(ctx context.Context, accountID, inboxID uint) error { inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID) if err != nil { - return nil, fmt.Errorf("inbox not found: %w", err) + return fmt.Errorf("inbox not found: %w", err) } if inbox.ChannelType != "whatsapp" { - return nil, fmt.Errorf("sync_templates is only supported for WhatsApp inboxes") + return ErrInboxTemplateSyncWhatsAppOnly } - // Get the WhatsApp channel record waChannel, err := s.getWhatsAppChannel(ctx, inbox.ID) if err != nil { - return nil, fmt.Errorf("failed to get WhatsApp channel: %w", err) + return fmt.Errorf("failed to get WhatsApp channel: %w", err) + } + if s.worker == nil { + return fmt.Errorf("worker pool not available") } - // Fetch templates from the WhatsApp Business API - templates, err := s.fetchWhatsAppTemplates(ctx, waChannel) - if err != nil { - return nil, fmt.Errorf("failed to fetch WhatsApp templates: %w", err) - } - - applogger.L().Infof("Synced WhatsApp templates for inbox %d (account_id=%d), got %d templates", - inboxID, accountID, len(templates)) - - return templates, nil + _, err = s.worker.Enqueue(ctx, TaskTypeInboxSyncTemplates, inboxTemplateSyncJob{AccountID: accountID, InboxID: inbox.ID, ChannelID: waChannel.ID}, worker.WithQueue("low"), worker.WithMaxAttempts(3)) + return err } // RegisterWebhookRequest represents the request body for registering a webhook on an inbox. @@ -1752,6 +1754,51 @@ func (s *InboxService) fetchWhatsAppTemplates(ctx context.Context, waChannel *ch return s.whatsappService.FetchMessageTemplates(ctx, waChannel) } +type inboxTemplateSyncJob struct { + AccountID uint `json:"account_id"` + InboxID uint `json:"inbox_id"` + ChannelID uint `json:"channel_id"` +} + +func RegisterInboxTemplateSyncJobs(wp *worker.WorkerPool, svc *InboxService) { + if wp == nil || svc == nil { + return + } + wp.Register(TaskTypeInboxSyncTemplates, svc.performTemplateSyncJob) +} + +func (s *InboxService) performTemplateSyncJob(ctx context.Context, job *model.BackgroundJob) error { + var payload inboxTemplateSyncJob + if len(job.Payload) > 0 { + if err := json.Unmarshal(job.Payload, &payload); err != nil { + return fmt.Errorf("unmarshal inbox template sync job: %w", err) + } + } + if payload.AccountID == 0 || payload.InboxID == 0 { + return fmt.Errorf("invalid inbox template sync job payload: %#v", payload) + } + inbox, err := s.repo.FindByAccountAndID(ctx, payload.AccountID, payload.InboxID) + if err != nil { + return fmt.Errorf("inbox not found: %w", err) + } + if inbox.ChannelType != "whatsapp" { + return ErrInboxTemplateSyncWhatsAppOnly + } + waChannel, err := s.getWhatsAppChannel(ctx, inbox.ID) + if err != nil { + return fmt.Errorf("failed to get WhatsApp channel: %w", err) + } + if payload.ChannelID != 0 && payload.ChannelID != waChannel.ID { + return fmt.Errorf("WhatsApp channel mismatch for inbox_id=%d", inbox.ID) + } + templates, err := s.fetchWhatsAppTemplates(ctx, waChannel) + if err != nil { + return fmt.Errorf("failed to fetch WhatsApp templates: %w", err) + } + applogger.L().Infof("Synced WhatsApp templates for inbox %d (account_id=%d), got %d templates", inbox.ID, inbox.AccountID, len(templates)) + return nil +} + func (s *InboxService) fetchWhatsAppHealthStatus(ctx context.Context, waChannel *channelmodel.ChannelWhatsApp) (map[string]interface{}, error) { if s.whatsappService == nil { return nil, fmt.Errorf("WhatsApp service not available") diff --git a/internal/service/inbox_service_test.go b/internal/service/inbox_service_test.go index e1d3cb8c..f986e080 100644 --- a/internal/service/inbox_service_test.go +++ b/internal/service/inbox_service_test.go @@ -2,7 +2,7 @@ package service import ( "context" - "errors" + "encoding/json" "fmt" "testing" @@ -16,6 +16,7 @@ import ( "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/repository" + "github.com/gochat/gochat/internal/worker" ) // ========== Test Setup ========== @@ -37,6 +38,7 @@ func setupInboxServiceTest(t *testing.T) (*InboxService, *gorm.DB) { &model.Inbox{}, &model.AgentBotInbox{}, &model.WebhookSubscription{}, + &model.BackgroundJob{}, &channelmodel.ChannelWhatsApp{}, ), "failed to auto-migrate") @@ -58,12 +60,19 @@ func setupInboxServiceTest(t *testing.T) (*InboxService, *gorm.DB) { type fakeInboxWhatsAppService struct { healthPayload map[string]interface{} healthErr error + templates []interface{} + templateErr error + fetchCalls int webhookURL string webhookErr error } func (f *fakeInboxWhatsAppService) FetchMessageTemplates(context.Context, *channelmodel.ChannelWhatsApp) ([]interface{}, error) { - return nil, errors.New("not used") + f.fetchCalls++ + if f.templateErr != nil { + return nil, f.templateErr + } + return f.templates, nil } func (f *fakeInboxWhatsAppService) FetchHealthStatus(_ context.Context, _ *channelmodel.ChannelWhatsApp) (map[string]interface{}, error) { @@ -343,34 +352,52 @@ func TestInboxService_SyncTemplates_NonWhatsAppInbox(t *testing.T) { svc, db := setupInboxServiceTest(t) account, inbox := createInboxTestPrereqs(t, db, "api") - templates, err := svc.SyncTemplates(context.Background(), account.ID, inbox.ID) + err := svc.SyncTemplates(context.Background(), account.ID, inbox.ID) assert.Error(t, err) - assert.Nil(t, templates) - assert.Contains(t, err.Error(), "only supported for WhatsApp") + assert.Contains(t, err.Error(), "Template sync is only available for WhatsApp channels") } func TestInboxService_SyncTemplates_InboxNotFound(t *testing.T) { svc, _ := setupInboxServiceTest(t) - templates, err := svc.SyncTemplates(context.Background(), 9999, 9999) + err := svc.SyncTemplates(context.Background(), 9999, 9999) assert.Error(t, err) - assert.Nil(t, templates) assert.Contains(t, err.Error(), "inbox not found") } -// SyncTemplates for WhatsApp inboxes requires WhatsApp service/repo which are nil in tests. -// The WhatsApp path will fail at getWhatsAppChannel — we verify that it correctly -// rejects non-WhatsApp inboxes first (tested above), and for WhatsApp inboxes without -// the WhatsApp service, it should error out at the channel lookup step. - -func TestInboxService_SyncTemplates_WhatsAppInbox_NoWaService(t *testing.T) { +func TestInboxService_SyncTemplates_WhatsAppQueuesWorkerJob(t *testing.T) { svc, db := setupInboxServiceTest(t) - account, inbox := createInboxTestPrereqs(t, db, "whatsapp") + account, inbox, channel := createWhatsAppInboxTestPrereqs(t, db, "whatsapp_cloud") + wp := worker.NewWorkerPool(db) + svc.SetWorkerPool(wp) + svc.whatsappService = &fakeInboxWhatsAppService{} - templates, err := svc.SyncTemplates(context.Background(), account.ID, inbox.ID) - // Will fail because whatsapp service/repo are nil - assert.Error(t, err) - assert.Nil(t, templates) + err := svc.SyncTemplates(context.Background(), account.ID, inbox.ID) + require.NoError(t, err) + + var job model.BackgroundJob + require.NoError(t, db.Where("job_type = ? AND queue = ?", TaskTypeInboxSyncTemplates, "low").First(&job).Error) + var payload inboxTemplateSyncJob + require.NoError(t, json.Unmarshal(job.Payload, &payload)) + assert.Equal(t, account.ID, payload.AccountID) + assert.Equal(t, inbox.ID, payload.InboxID) + assert.Equal(t, channel.ID, payload.ChannelID) +} + +func TestInboxService_SyncTemplatesWorkerFetchesTemplates(t *testing.T) { + svc, db := setupInboxServiceTest(t) + account, inbox, _ := createWhatsAppInboxTestPrereqs(t, db, "whatsapp_cloud") + fake := &fakeInboxWhatsAppService{templates: []interface{}{map[string]interface{}{"name": "hello_world"}}} + svc.whatsappService = fake + wp := worker.NewWorkerPool(db) + svc.SetWorkerPool(wp) + RegisterInboxTemplateSyncJobs(wp, svc) + + require.NoError(t, svc.SyncTemplates(context.Background(), account.ID, inbox.ID)) + processed, err := wp.ProcessOne(context.Background()) + require.NoError(t, err) + assert.True(t, processed) + assert.Equal(t, 1, fake.fetchCalls) } // ========================================