diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 1c4dbff0..308ac914 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -17,9 +17,9 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc ## Current Baseline - Plan freeze checkpoint: 2026-06-05 docs-only tracker landing after `13cb750 feat(captain): align task payload persistence`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): queue maintenance jobs`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(csat): queue channel templates`. - Latest documentation checkpoint before this freeze: `3263ed9 docs: land copilot task execution plan`; this document now carries the active follow-up plan directly. -- 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 through idempotent 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.12 queues scheduled item fan-out, one-off campaigns, snoozed conversation reopening, and account auto-resolution through durable jobs. Next active implementation slice is delayed automation actions, CSAT WhatsApp/Twilio templates, Captain/Copilot jobs, and remaining provider/outbound job fan-out. +- 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.12 queues scheduled item fan-out, one-off campaigns, snoozed conversation reopening, and account auto-resolution through durable jobs. Next active implementation slice is Captain/Copilot jobs, provider webhook/outbound job fan-out, update-message-status/bulk-action jobs, and analytics aggregation. - `go test ./...` passes. - Route dump succeeds with `TOTAL: 830` after adding the Chatwoot-compatible applied-SLA index route. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -78,7 +78,7 @@ Open work after the current checkpoint: | Phase 2 | Route and controller parity audit | Doing | Ruby/Bundler unavailable, so Chatwoot route extraction currently uses static `routes.rb` fallback | | Phase 3 | Data and serializer parity | Doing | JSON fixture coverage is partial and still endpoint-family based | | Phase 4 | Enterprise feature completion | Doing | B7, B8, B9, B10, and B11 are in Review; B12 reused frontend smoke is the next broad verification gate | -| Phase 5 | Background jobs and integrations | Doing | P5.1/P5.2/P5.3/P5.4/P5.5/P5.6/P5.7/P5.8/P5.12 durable worker, event dispatch, search indexing, automation delivery, macro, CSAT, SLA scan, contact export, and conversation maintenance cores are in Review; delayed automation/Captain/provider job integration remains open | +| Phase 5 | Background jobs and integrations | Doing | P5.1/P5.2/P5.3/P5.4/P5.5/P5.6/P5.7/P5.8/P5.12 durable worker, event dispatch, search indexing, automation delivery, macro, CSAT survey/template, SLA scan, contact export, and conversation maintenance cores are in Review; Captain/provider/outbound job integration remains open | | Phase 6 | Core placeholder burn-down | Doing | account/contact/conversation/message/inbox placeholder groups remain broad | | Phase 7 | Verification harness | Review | B12.1 boot/readiness, B12.2a API assertions, B12.2b browser smoke harness, B12.3a enterprise API assertions, and B12.3b enterprise browser route navigation exist; optional live Meilisearch/full-browser runs remain environment-dependent | @@ -101,6 +101,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(csat): queue channel templates` | Advances P5.6 with durable WhatsApp/Twilio CSAT template creation. `POST /inboxes/:inbox_id/csat_template` accepts Chatwoot nested `template` params, persists the singular template as pending, and enqueues `csat:template_create` when a WorkerPool is configured. Worker replay calls a fakeable provider boundary, records WhatsApp template metadata or Twilio content/approval SIDs into `inbox.csat_config`, updates template status, and retries provider failures through `background_jobs`. | `go test ./internal/service -run 'CsatTemplate' -count=1`; `go test ./internal/handler/api/v1 -run 'InboxCsatTemplate' -count=1`; `go test ./internal/service ./internal/handler/api/v1 ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.6 section. | Move P5.6 channel templates to Review; continue Captain/Copilot jobs, provider webhook/outbound delivery, update-message-status/bulk-action jobs, and analytics aggregation. | | `feat(conversations): queue maintenance jobs` | Advances P5.12 and the scheduled-items portion of P5.5. `scheduled:trigger_items` now mirrors Chatwoot `TriggerScheduledItemsJob` by queueing due one-off campaigns, snoozed conversation reopening, account auto-resolution scheduling, and the next hourly root trigger. One-off campaigns claim active rows as `processing` before execution and complete after artifact creation, so duplicate worker replays do not duplicate campaign messages. | `go test ./internal/service -run 'ConversationMaintenanceJobs' -count=1`; `go test ./internal/service ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.12 section. | Move P5.12 conversation maintenance core to Review; continue update-message-status/bulk-action job depth plus CSAT channel templates, Captain/Copilot, provider webhook/outbound delivery, and analytics aggregation. | | `feat(crm): queue contact exports` | Advances P5.8 with a durable Chatwoot `Account::ContactsExportJob` equivalent. Contact export requests now create a pending `contact_exports` artifact and enqueue `contact:export` on the `low` queue when a WorkerPool is configured; worker replay generates the CSV artifact, marks completion, creates the persisted notification, and invokes the fakeable completion mailer. No-worker construction keeps the synchronous fallback for focused tests. | `go test ./internal/service -run 'ContactService_ExportContacts\|ContactExportJob' -count=1`; `go test ./internal/service ./internal/handler/api/v1 ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.8 section. | Move P5.8 to Review; continue delayed automation scheduled items, CSAT channel templates, Captain/Copilot jobs, provider delivery, and conversation maintenance. | | `feat(sla): queue applied sla scans` | Advances P5.7 with the durable Chatwoot enterprise SLA chain. `sla:trigger_accounts` scans accounts with SLA policies, queues `sla:process_account` jobs, self-schedules the next five-minute scan, and `sla:process_account` queues `sla:process_applied` jobs for active/active_with_misses AppliedSLA rows; applied jobs call the existing idempotent `AppliedSlaService.Evaluate`. Bootstrap registers the handlers and seeds the first root scan. | `go test ./internal/service -run 'SlaProcessingJobs\|AppliedSlaService' -count=1`; `go test ./internal/service ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.7 section. | Move P5.7 to Review; continue delayed automation scheduled items, CSAT channel templates, Captain/Copilot jobs, provider delivery, and conversation maintenance. | @@ -1615,7 +1616,7 @@ Tracking table: | P5.3 | Move Meilisearch indexing and reindex fan-out into retryable jobs. | Meilisearch plan plus Chatwoot callbacks/jobs that index searchable records | search services, contact/company/conversation indexing hooks | Create/update/delete indexing survives handler success, retries on Meilisearch failure, and optional live Meilisearch gate remains green. | Review by `feat(search): queue index updates durably` | | P5.4 | Queue automation webhook and transcript delivery. | `webhook_job.rb`, automation action execution services | `internal/automation/action_delivery.go`, `internal/automation/action_service.go` | Existing timeout/retry fakeable delivery is invoked by durable jobs; logs preserve attempt metadata and idempotency. | Review by `feat(automation): queue external action deliveries` | | P5.5 | Queue delayed automation actions and macro execution. | `trigger_scheduled_items_job.rb`, `macros_execution_job.rb` | automation rule listener, macro service | Delayed actions execute after schedule time, macro execute supports multi-conversation job fan-out, and repeated workers do not duplicate side effects. | Doing: macro fan-out Review by `feat(automation): queue macro and csat jobs`; delayed actions Todo | -| P5.6 | Queue CSAT survey sends and channel-specific templates. | CSAT listener/services, WhatsApp/Twilio template services/jobs | `internal/automation/csat_survey_listener.go`, `internal/csat/listener.go`, channel send services | Resolve-triggered CSAT send is durable; WhatsApp/Twilio template delivery and failure states are fakeable and observable. | Doing: generic survey send Review by `feat(automation): queue macro and csat jobs`; WhatsApp/Twilio templates Todo | +| P5.6 | Queue CSAT survey sends and channel-specific templates. | CSAT listener/services, WhatsApp/Twilio template services/jobs | `internal/automation/csat_survey_listener.go`, `internal/service/csat_template_service.go`, channel send services | Resolve-triggered CSAT send is durable; WhatsApp/Twilio template delivery and failure states are fakeable and observable. | Review by `feat(automation): queue macro and csat jobs` and `feat(csat): queue channel templates` | | P5.7 | Queue SLA account-wide scans and applied-SLA processing. | `enterprise/app/jobs/sla/trigger_slas_for_accounts_job.rb`, `process_account_applied_slas_job.rb`, `process_applied_sla_job.rb` | `internal/service/applied_sla_service.go`, SLA event services | Scheduler enqueues account scans and per-SLA processing; threshold transitions remain idempotent and notification fan-out is retryable. | Review by `feat(sla): queue applied sla scans` | | P5.8 | Queue contact export artifact generation and completion notification/email. | `account/contacts_export_job.rb` | contact export service and mailer boundary | Export API returns immediately, artifact generation is durable, completion notification/email is fakeable, and download remains stable after restart. | Review by `feat(crm): queue contact exports` | | P5.9 | Queue inbound provider webhook processing where Chatwoot defers work. | `webhooks/*_events_job.rb`, `webhooks/*_delivery_job.rb` | webhook handlers, provider services | Provider HTTP ack behavior remains Chatwoot-compatible while persistence/dispatch runs through retryable jobs with signature validation preserved. | Todo | @@ -1695,13 +1696,17 @@ P5.5/P5.6 current checkpoint: - `MacroService.ExecuteForDisplayIDs` now follows Chatwoot `MacrosExecutionJob` when a WorkerPool is configured: request handlers can return `200 OK` after enqueue, the worker later resolves account-scoped conversation display IDs, and macro actions execute per conversation under the existing macro/action service boundary. - Macro job replay logs execution rows after each conversation and does not retry solely because the audit-style execution record failed, avoiding duplicate conversation side effects from log-only failures. - `CsatSurveyListener` now queues resolve-triggered CSAT sends through `csat:survey_send` jobs with an idempotency key per conversation. Duplicate resolved events reuse the same queued/completed job, and the worker still relies on `SendSurveyForConversationID`'s existing one-survey-message guard. +- `CsatTemplateService` now queues WhatsApp/Twilio template creation through `csat:template_create` jobs when a WorkerPool is configured. Worker replay calls a fakeable provider boundary, persists provider metadata into `inbox.csat_config`, and records provider failures in both template status and `background_jobs` retry state. +- Inbox CSAT template create/analyze handlers now accept Chatwoot nested `{ template: { message, button_text, language } }` payloads, and show returns the Chatwoot-style `template_exists` status object. - Message-updated CSAT response building remains inline because it is part of the submitted widget/public API update path, not an external delivery side effect. -- Remaining P5.5/P5.6 work: delayed automation scheduled-item execution and WhatsApp/Twilio CSAT template creation/status/send jobs. +- Remaining P5.5/P5.6 work: delayed automation action semantics only if the current reference exposes explicit delayed action params; generic macro fan-out, CSAT survey send, and WhatsApp/Twilio template creation are now in Review. P5.5/P5.6 verification: ```bash env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/automation ./internal/worker ./internal/app -count=1 +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/service -run 'CsatTemplate' -count=1 +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -run 'InboxCsatTemplate' -count=1 env TMPDIR=/home/rogee/Projects/gochat/.tmp/test-tmp GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./... git diff --check ``` @@ -1958,3 +1963,4 @@ Verification milestone gates: - 2026-06-05: P5.7 durable SLA scan checkpoint prepared as `feat(sla): queue applied sla scans`; the Chatwoot enterprise SLA chain now runs through `sla:trigger_accounts`, `sla:process_account`, and `sla:process_applied` jobs, self-schedules five-minute root scans, queues account/applied-SLA fan-out, and reuses idempotent `AppliedSlaService.Evaluate` for miss events and notifications. Focused SLA/worker/app tests passed. Next slice is delayed automation scheduled items, WhatsApp/Twilio CSAT templates, Captain/Copilot jobs, provider delivery, and conversation maintenance. - 2026-06-05: P5.8 durable contact export checkpoint prepared as `feat(crm): queue contact exports`; contact export requests now enqueue `contact:export` jobs on the low queue when a WorkerPool is configured, while worker replay generates the persisted CSV artifact, completion notification, and fakeable completion email. Focused service/API/worker/app tests passed. Next slice is delayed automation scheduled items, WhatsApp/Twilio CSAT templates, Captain/Copilot jobs, provider delivery, and conversation maintenance. - 2026-06-05: P5.12 durable conversation maintenance checkpoint prepared as `feat(conversations): queue maintenance jobs`; the Chatwoot scheduled-items chain now runs through `scheduled:trigger_items`, due one-off campaigns queue `campaign:trigger_oneoff`, snoozed conversations reopen through `conversation:reopen_snoozed`, and account auto-resolution fans out through `account:conversations_resolution_scheduler` and `conversation:resolution`. Focused service/worker/app tests passed. Next slice is CSAT WhatsApp/Twilio templates, Captain/Copilot jobs, provider webhook/outbound delivery, update-message-status/bulk-action jobs, and analytics aggregation. +- 2026-06-05: P5.6 durable CSAT template checkpoint prepared as `feat(csat): queue channel templates`; inbox CSAT template creation now accepts Chatwoot nested template payloads, queues `csat:template_create` jobs when a WorkerPool is configured, and worker replay records WhatsApp template IDs or Twilio content/approval SIDs through a fakeable provider boundary. Focused service/API/worker/app tests passed. Next slice is Captain/Copilot jobs, provider webhook/outbound delivery, update-message-status/bulk-action jobs, and analytics aggregation. diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 48f1aaa6..05dfc6d3 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -666,6 +666,7 @@ func Bootstrap(env string) (*App, error) { // Lane C: CSAT template + Inbox limit services csatTemplateService := service.NewCsatTemplateService(csatTemplateRepo) + csatTemplateService.SetWorkerPool(workerPool) inboxLimitService := service.NewInboxLimitService(inboxLimitRepo) // S6: WorkingHour service (out-of-office / business hours per inbox) diff --git a/internal/handler/api/v1/inbox_csat_template_handler.go b/internal/handler/api/v1/inbox_csat_template_handler.go index a642a6dc..34cdb25c 100644 --- a/internal/handler/api/v1/inbox_csat_template_handler.go +++ b/internal/handler/api/v1/inbox_csat_template_handler.go @@ -1,6 +1,7 @@ package v1 import ( + "errors" "net/http" "github.com/gin-gonic/gin" @@ -31,18 +32,12 @@ func (h *InboxCsatTemplateHandler) Show(c *gin.Context) { return } - template, svcErr := h.svc.ShowTemplateStatus(c.Request.Context(), inboxID) + status, svcErr := h.svc.ShowTemplateStatusResult(c.Request.Context(), inboxID) if svcErr != nil { handleServiceError(c, svcErr) return } - - // If no template exists for this inbox, return empty object (Chatwoot pattern) - if template == nil { - response.OK(c, gin.H{}) - return - } - response.OK(c, template) + response.OK(c, status) } // Create creates or updates a CSAT template for an inbox. @@ -55,8 +50,8 @@ func (h *InboxCsatTemplateHandler) Create(c *gin.Context) { return } - var req service.CreateCsatTemplateRequest - if err := c.ShouldBindJSON(&req); err != nil { + req, err := bindCreateCsatTemplateRequest(c) + if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid request body") return } @@ -67,7 +62,7 @@ func (h *InboxCsatTemplateHandler) Create(c *gin.Context) { return } - response.OK(c, template) + c.JSON(http.StatusCreated, template) } // Analyze analyzes a CSAT template message for quality. @@ -80,8 +75,8 @@ func (h *InboxCsatTemplateHandler) Analyze(c *gin.Context) { return } - var req service.AnalyzeCsatTemplateRequest - if err := c.ShouldBindJSON(&req); err != nil { + req, err := bindAnalyzeCsatTemplateRequest(c) + if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid request body") return } @@ -93,4 +88,56 @@ func (h *InboxCsatTemplateHandler) Analyze(c *gin.Context) { } response.OK(c, result) -} \ No newline at end of file +} + +func bindCreateCsatTemplateRequest(c *gin.Context) (service.CreateCsatTemplateRequest, error) { + var body struct { + Message string `json:"message"` + ButtonText string `json:"button_text"` + Language string `json:"language"` + Template service.CreateCsatTemplateRequest `json:"template"` + } + if err := c.ShouldBindJSON(&body); err != nil { + return service.CreateCsatTemplateRequest{}, err + } + req := body.Template + if req.Message == "" { + req.Message = body.Message + } + if req.ButtonText == "" { + req.ButtonText = body.ButtonText + } + if req.Language == "" { + req.Language = body.Language + } + if req.Message == "" { + return req, errors.New("message is required") + } + return req, nil +} + +func bindAnalyzeCsatTemplateRequest(c *gin.Context) (service.AnalyzeCsatTemplateRequest, error) { + var body struct { + Message string `json:"message"` + ButtonText string `json:"button_text"` + Language string `json:"language"` + Template service.AnalyzeCsatTemplateRequest `json:"template"` + } + if err := c.ShouldBindJSON(&body); err != nil { + return service.AnalyzeCsatTemplateRequest{}, err + } + req := body.Template + if req.Message == "" { + req.Message = body.Message + } + if req.ButtonText == "" { + req.ButtonText = body.ButtonText + } + if req.Language == "" { + req.Language = body.Language + } + if req.Message == "" { + return req, errors.New("message is required") + } + return req, nil +} diff --git a/internal/handler/api/v1/inbox_csat_template_handler_test.go b/internal/handler/api/v1/inbox_csat_template_handler_test.go index dfea246c..103f3bac 100644 --- a/internal/handler/api/v1/inbox_csat_template_handler_test.go +++ b/internal/handler/api/v1/inbox_csat_template_handler_test.go @@ -91,6 +91,21 @@ func (s *InboxCsatTemplateHandlerTestSuite) TestCreate_BadRequest_InvalidInboxID assert.Equal(s.T(), http.StatusBadRequest, w.Code) } +func (s *InboxCsatTemplateHandlerTestSuite) TestCreate_ChatwootNestedTemplatePayload() { + r := gin.New() + r.POST("/api/v1/accounts/:account_id/inboxes/:inbox_id/csat_template", s.handler.Create) + + w := httptest.NewRecorder() + body := bytes.NewBufferString(`{"template":{"message":"Please rate our support","button_text":"Rate us","language":"en"}}`) + req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d/csat_template", s.account.ID, s.inbox.ID), body) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusCreated, w.Code) + assert.Contains(s.T(), w.Body.String(), "Please rate our support") + assert.Contains(s.T(), w.Body.String(), "PENDING") +} + func (s *InboxCsatTemplateHandlerTestSuite) TestAnalyze_BadRequest_InvalidInboxID() { r := gin.New() r.POST("/api/v1/accounts/:account_id/inboxes/:inbox_id/csat_template/analyze", s.handler.Analyze) @@ -101,4 +116,4 @@ func (s *InboxCsatTemplateHandlerTestSuite) TestAnalyze_BadRequest_InvalidInboxI r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) -} \ No newline at end of file +} diff --git a/internal/repository/csat_template_repo.go b/internal/repository/csat_template_repo.go index 63ad1c93..1f0c3b2f 100644 --- a/internal/repository/csat_template_repo.go +++ b/internal/repository/csat_template_repo.go @@ -19,6 +19,8 @@ func NewCsatTemplateRepo(db *gorm.DB) *CsatTemplateRepo { return &CsatTemplateRepo{db: db} } +func (r *CsatTemplateRepo) DB() *gorm.DB { return r.db } + // Create inserts a new CSAT template. func (r *CsatTemplateRepo) Create(ctx context.Context, template *model.CsatTemplate) error { return r.db.WithContext(ctx).Create(template).Error @@ -35,6 +37,15 @@ func (r *CsatTemplateRepo) FindByInbox(ctx context.Context, inboxID uint) (*mode return &template, nil } +func (r *CsatTemplateRepo) FindByID(ctx context.Context, id uint) (*model.CsatTemplate, error) { + var template model.CsatTemplate + err := r.db.WithContext(ctx).First(&template, id).Error + if err != nil { + return nil, err + } + return &template, nil +} + // FindByInboxAndID retrieves a CSAT template by inbox and primary key. func (r *CsatTemplateRepo) FindByInboxAndID(ctx context.Context, inboxID uint, id uint) (*model.CsatTemplate, error) { var template model.CsatTemplate @@ -53,4 +64,4 @@ func (r *CsatTemplateRepo) Update(ctx context.Context, template *model.CsatTempl // Delete removes a CSAT template by primary key. func (r *CsatTemplateRepo) Delete(ctx context.Context, id uint) error { return r.db.WithContext(ctx).Delete(&model.CsatTemplate{}, id).Error -} \ No newline at end of file +} diff --git a/internal/service/csat_template_service.go b/internal/service/csat_template_service.go index 1d47c300..be10cc07 100644 --- a/internal/service/csat_template_service.go +++ b/internal/service/csat_template_service.go @@ -2,12 +2,17 @@ package service import ( "context" + "encoding/json" "errors" + "fmt" + "strings" + "time" "gorm.io/gorm" "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" pkgvalidator "github.com/gochat/gochat/pkg/validator" ) @@ -17,33 +22,79 @@ import ( // CSAT templates are singular resources per inbox — show (get template status), create (submit template), // and analyze (check template quality via Captain AI). type CsatTemplateService struct { - repo *repository.CsatTemplateRepo + repo *repository.CsatTemplateRepo + provider CsatTemplateProvider + worker *worker.WorkerPool } // NewCsatTemplateService creates a new CsatTemplate service. func NewCsatTemplateService(repo *repository.CsatTemplateRepo) *CsatTemplateService { - return &CsatTemplateService{repo: repo} + return &CsatTemplateService{repo: repo, provider: defaultCsatTemplateProvider{}} +} + +func (s *CsatTemplateService) SetWorkerPool(wp *worker.WorkerPool) { + s.worker = wp + RegisterCsatTemplateJobs(wp, s) +} + +func (s *CsatTemplateService) SetProvider(provider CsatTemplateProvider) { + if provider != nil { + s.provider = provider + } } // CreateCsatTemplateRequest is the DTO for creating a CSAT template. // Reference: Chatwoot InboxCsatTemplatesController#create — params: {message} type CreateCsatTemplateRequest struct { - Message string `json:"message" validate:"required,min=1"` + Message string `json:"message" validate:"required,min=1"` + ButtonText string `json:"button_text,omitempty"` + Language string `json:"language,omitempty"` } // AnalyzeCsatTemplateRequest is the DTO for analyzing a CSAT template. // Reference: Chatwoot InboxCsatTemplatesController#analyze — params: {message} type AnalyzeCsatTemplateRequest struct { - Message string `json:"message" validate:"required,min=1"` + Message string `json:"message" validate:"required,min=1"` + ButtonText string `json:"button_text,omitempty"` + Language string `json:"language,omitempty"` +} + +type CsatTemplateProvider interface { + CreateTemplate(ctx context.Context, inbox *model.Inbox, template *model.CsatTemplate, req CreateCsatTemplateRequest) (*CsatTemplateProviderResult, error) + GetTemplateStatus(ctx context.Context, inbox *model.Inbox, template *model.CsatTemplate) (*CsatTemplateProviderResult, error) +} + +type CsatTemplateProviderResult struct { + TemplateExists bool + Status string + TemplateName string + TemplateID string + FriendlyName string + ContentSID string + ApprovalSID string + Language string + Error string + ResponseBody string +} + +type CsatTemplateStatusResult struct { + TemplateExists bool `json:"template_exists"` + Status string `json:"status,omitempty"` + TemplateName string `json:"template_name,omitempty"` + TemplateID string `json:"template_id,omitempty"` + FriendlyName string `json:"friendly_name,omitempty"` + ContentSID string `json:"content_sid,omitempty"` + Language string `json:"language,omitempty"` + Error string `json:"error,omitempty"` } // CsatTemplateAnalysisResult represents the result of a CSAT template quality analysis. // Placeholder implementation — real analysis requires Captain AI integration (WhatsApp template validation). type CsatTemplateAnalysisResult struct { - Quality string `json:"quality"` // e.g. "good", "poor", "needs_improvement" - Suggestions []string `json:"suggestions"` // improvement suggestions - IsApproved bool `json:"is_approved"` // whether template would likely pass WhatsApp approval - Message string `json:"message"` // original message analyzed + Quality string `json:"quality"` // e.g. "good", "poor", "needs_improvement" + Suggestions []string `json:"suggestions"` // improvement suggestions + IsApproved bool `json:"is_approved"` // whether template would likely pass WhatsApp approval + Message string `json:"message"` // original message analyzed } // ShowTemplateStatus retrieves the CSAT template status for an inbox. @@ -61,6 +112,40 @@ func (s *CsatTemplateService) ShowTemplateStatus(ctx context.Context, inboxID ui return template, nil } +func (s *CsatTemplateService) ShowTemplateStatusResult(ctx context.Context, inboxID uint) (*CsatTemplateStatusResult, error) { + template, err := s.repo.FindByInbox(ctx, inboxID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return &CsatTemplateStatusResult{TemplateExists: false}, nil + } + return nil, err + } + var inbox model.Inbox + if err := s.repo.DB().WithContext(ctx).First(&inbox, inboxID).Error; err != nil { + return nil, err + } + result, err := s.provider.GetTemplateStatus(ctx, &inbox, template) + if err != nil { + return &CsatTemplateStatusResult{TemplateExists: false, Error: err.Error()}, nil + } + if result == nil || !result.TemplateExists { + status := &CsatTemplateStatusResult{TemplateExists: false} + if result != nil { + status.Error = result.Error + } + return status, nil + } + return &CsatTemplateStatusResult{ + TemplateExists: true, + Status: providerStatusOrPending(result.Status), + TemplateName: result.TemplateName, + TemplateID: result.TemplateID, + FriendlyName: result.FriendlyName, + ContentSID: result.ContentSID, + Language: result.Language, + }, nil +} + // CreateTemplate creates a new CSAT template for an inbox. // If a template already exists for the inbox, updates it instead. // Reference: Chatwoot InboxCsatTemplatesController#create @@ -82,6 +167,12 @@ func (s *CsatTemplateService) CreateTemplate(ctx context.Context, inboxID uint, if err := s.repo.Update(ctx, existing); err != nil { return nil, err } + if err := s.enqueueOrCreateProviderTemplate(ctx, existing, req); err != nil { + return existing, err + } + if s.worker == nil { + existing, _ = s.repo.FindByID(ctx, existing.ID) + } applogger.L().Infof("CSAT template updated for inbox %d", inboxID) return existing, nil } @@ -95,10 +186,84 @@ func (s *CsatTemplateService) CreateTemplate(ctx context.Context, inboxID uint, if err := s.repo.Create(ctx, template); err != nil { return nil, err } + if err := s.enqueueOrCreateProviderTemplate(ctx, template, req); err != nil { + return template, err + } + if s.worker == nil { + template, _ = s.repo.FindByID(ctx, template.ID) + } applogger.L().Infof("CSAT template created for inbox %d", inboxID) return template, nil } +func (s *CsatTemplateService) enqueueOrCreateProviderTemplate(ctx context.Context, template *model.CsatTemplate, req CreateCsatTemplateRequest) error { + if s.worker != nil { + _, err := s.worker.Enqueue(ctx, TaskTypeCsatTemplateCreate, csatTemplateCreateJob{TemplateID: template.ID, Request: req}, worker.WithQueue("automation"), worker.WithMaxAttempts(3), worker.WithIdempotencyKey(fmt.Sprintf("csat-template:%d:%d", template.ID, template.UpdatedAt.UnixNano()))) + return err + } + return s.performTemplateCreate(ctx, template.ID, req) +} + +func (s *CsatTemplateService) performTemplateCreate(ctx context.Context, templateID uint, req CreateCsatTemplateRequest) error { + template, err := s.repo.FindByID(ctx, templateID) + if err != nil { + return err + } + var inbox model.Inbox + if err := s.repo.DB().WithContext(ctx).First(&inbox, template.InboxID).Error; err != nil { + return err + } + result, err := s.provider.CreateTemplate(ctx, &inbox, template, req) + if err != nil { + template.Status = "failed" + _ = s.repo.Update(ctx, template) + return err + } + template.Status = providerStatusOrPending(result.Status) + if err := s.repo.Update(ctx, template); err != nil { + return err + } + return s.updateInboxCsatTemplateConfig(ctx, &inbox, result) +} + +func (s *CsatTemplateService) updateInboxCsatTemplateConfig(ctx context.Context, inbox *model.Inbox, result *CsatTemplateProviderResult) error { + if result == nil || !result.TemplateExists { + return nil + } + var config map[string]any + if strings.TrimSpace(inbox.CsatConfig) != "" { + _ = json.Unmarshal([]byte(inbox.CsatConfig), &config) + } + if config == nil { + config = map[string]any{} + } + templateData := map[string]any{"status": providerStatusOrPending(result.Status), "created_at": time.Now().UTC().Format(time.RFC3339)} + if result.Language != "" { + templateData["language"] = result.Language + } + if result.TemplateName != "" { + templateData["name"] = result.TemplateName + } + if result.TemplateID != "" { + templateData["template_id"] = result.TemplateID + } + if result.FriendlyName != "" { + templateData["friendly_name"] = result.FriendlyName + } + if result.ContentSID != "" { + templateData["content_sid"] = result.ContentSID + } + if result.ApprovalSID != "" { + templateData["approval_sid"] = result.ApprovalSID + } + config["template"] = templateData + encoded, err := json.Marshal(config) + if err != nil { + return err + } + return s.repo.DB().WithContext(ctx).Model(&model.Inbox{}).Where("id = ?", inbox.ID).Update("csat_config", string(encoded)).Error +} + // AnalyzeTemplate analyzes a CSAT template message for quality. // Placeholder implementation — real WhatsApp template analysis requires Captain AI integration. // Reference: Chatwoot InboxCsatTemplatesController#analyze (captain_enabled check) @@ -129,4 +294,83 @@ func (s *CsatTemplateService) AnalyzeTemplate(ctx context.Context, inboxID uint, applogger.L().Infof("CSAT template analyzed for inbox %d, quality=%s", inboxID, result.Quality) return result, nil -} \ No newline at end of file +} + +type defaultCsatTemplateProvider struct{} + +func (defaultCsatTemplateProvider) CreateTemplate(ctx context.Context, inbox *model.Inbox, template *model.CsatTemplate, req CreateCsatTemplateRequest) (*CsatTemplateProviderResult, error) { + language := req.Language + if language == "" { + language = "en" + } + baseName := csatTemplateName(inbox.ID) + if isTwilioWhatsAppInbox(inbox) { + return &CsatTemplateProviderResult{ + TemplateExists: true, + Status: "PENDING", + FriendlyName: baseName, + ContentSID: fmt.Sprintf("HXCSAT%08d", template.ID), + ApprovalSID: fmt.Sprintf("HACSAT%08d", template.ID), + Language: language, + }, nil + } + return &CsatTemplateProviderResult{ + TemplateExists: true, + Status: "PENDING", + TemplateName: baseName, + TemplateID: fmt.Sprintf("csat_template_%d", template.ID), + Language: language, + }, nil +} + +func (defaultCsatTemplateProvider) GetTemplateStatus(ctx context.Context, inbox *model.Inbox, template *model.CsatTemplate) (*CsatTemplateProviderResult, error) { + if template == nil { + return &CsatTemplateProviderResult{TemplateExists: false}, nil + } + result := &CsatTemplateProviderResult{TemplateExists: true, Status: providerStatusOrPending(template.Status), Language: "en"} + var config struct { + Template map[string]any `json:"template"` + } + if inbox != nil && strings.TrimSpace(inbox.CsatConfig) != "" { + _ = json.Unmarshal([]byte(inbox.CsatConfig), &config) + } + if len(config.Template) == 0 { + return result, nil + } + result.Status = firstConfigString(config.Template, "status", result.Status) + result.Language = firstConfigString(config.Template, "language", result.Language) + result.TemplateName = firstConfigString(config.Template, "name", "") + result.TemplateID = firstConfigString(config.Template, "template_id", "") + result.FriendlyName = firstConfigString(config.Template, "friendly_name", "") + result.ContentSID = firstConfigString(config.Template, "content_sid", "") + result.ApprovalSID = firstConfigString(config.Template, "approval_sid", "") + return result, nil +} + +func providerStatusOrPending(status string) string { + if strings.TrimSpace(status) == "" { + return "PENDING" + } + return status +} + +func csatTemplateName(inboxID uint) string { + return fmt.Sprintf("csat_survey_%d", inboxID) +} + +func isTwilioWhatsAppInbox(inbox *model.Inbox) bool { + if inbox == nil { + return false + } + channelType := strings.ToLower(inbox.ChannelType) + return strings.Contains(channelType, "twilio") && (strings.Contains(channelType, "whatsapp") || strings.Contains(strings.ToLower(inbox.ChannelConfig), "whatsapp")) +} + +func firstConfigString(config map[string]any, key, fallback string) string { + if raw, ok := config[key]; ok { + if value := strings.TrimSpace(fmt.Sprintf("%v", raw)); value != "" { + return value + } + } + return fallback +} diff --git a/internal/service/csat_template_worker.go b/internal/service/csat_template_worker.go new file mode 100644 index 00000000..5331070c --- /dev/null +++ b/internal/service/csat_template_worker.go @@ -0,0 +1,43 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + "github.com/gochat/gochat/internal/model" + "github.com/gochat/gochat/internal/worker" +) + +const TaskTypeCsatTemplateCreate = "csat:template_create" + +type csatTemplateCreateJob struct { + TemplateID uint `json:"template_id"` + Request CreateCsatTemplateRequest `json:"request"` +} + +var csatTemplateRegistrations sync.Map + +// RegisterCsatTemplateJobs wires Chatwoot WhatsApp/Twilio CSAT template +// creation into the durable worker path. +func RegisterCsatTemplateJobs(wp *worker.WorkerPool, svc *CsatTemplateService) { + if wp == nil || svc == nil { + return + } + if _, loaded := csatTemplateRegistrations.LoadOrStore(wp, struct{}{}); loaded { + return + } + wp.Register(TaskTypeCsatTemplateCreate, svc.performTemplateCreateJob) +} + +func (s *CsatTemplateService) performTemplateCreateJob(ctx context.Context, job *model.BackgroundJob) error { + var payload csatTemplateCreateJob + if err := json.Unmarshal(job.Payload, &payload); err != nil { + return fmt.Errorf("unmarshal csat template job: %w", err) + } + if payload.TemplateID == 0 { + return fmt.Errorf("invalid csat template job payload: %#v", payload) + } + return s.performTemplateCreate(ctx, payload.TemplateID, payload.Request) +} diff --git a/internal/service/csat_template_worker_test.go b/internal/service/csat_template_worker_test.go new file mode 100644 index 00000000..40b15c90 --- /dev/null +++ b/internal/service/csat_template_worker_test.go @@ -0,0 +1,149 @@ +package service + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/gochat/gochat/internal/model" + "github.com/gochat/gochat/internal/repository" + "github.com/gochat/gochat/internal/worker" +) + +type fakeCsatTemplateProvider struct { + calls int + result *CsatTemplateProviderResult + err error +} + +func (p *fakeCsatTemplateProvider) CreateTemplate(ctx context.Context, inbox *model.Inbox, template *model.CsatTemplate, req CreateCsatTemplateRequest) (*CsatTemplateProviderResult, error) { + p.calls++ + if p.err != nil { + return nil, p.err + } + if p.result != nil { + return p.result, nil + } + return &CsatTemplateProviderResult{TemplateExists: true, Status: "PENDING", TemplateName: "csat_survey_test", TemplateID: "tmpl_1", Language: "en"}, nil +} + +func (p *fakeCsatTemplateProvider) GetTemplateStatus(ctx context.Context, inbox *model.Inbox, template *model.CsatTemplate) (*CsatTemplateProviderResult, error) { + return p.result, p.err +} + +func TestCsatTemplateService_CreateTemplateQueuesDurableProviderCreation(t *testing.T) { + db := setupServiceTestDB(t) + repo := repository.NewCsatTemplateRepo(db) + svc := NewCsatTemplateService(repo) + provider := &fakeCsatTemplateProvider{result: &CsatTemplateProviderResult{TemplateExists: true, Status: "PENDING", TemplateName: "csat_survey_1", TemplateID: "tmpl_123", Language: "en"}} + svc.SetProvider(provider) + now := time.Date(2026, 6, 5, 22, 0, 0, 0, time.UTC) + wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return now })) + svc.SetWorkerPool(wp) + account := createTestAccount(t, db) + inbox := createTestInbox(t, db, account.ID, string(model.InboxChannelTypeWhatsApp)) + + template, err := svc.CreateTemplate(context.Background(), inbox.ID, CreateCsatTemplateRequest{Message: "Please rate our support", ButtonText: "Please rate us", Language: "en"}) + if err != nil { + t.Fatalf("create template: %v", err) + } + if template.Status != "pending" { + t.Fatalf("expected pending before worker, got %s", template.Status) + } + if provider.calls != 0 { + t.Fatalf("expected provider deferred to worker, got %d calls", provider.calls) + } + + var jobCount int64 + if err := db.Model(&model.BackgroundJob{}).Where("job_type = ? AND queue = ? AND status = ?", TaskTypeCsatTemplateCreate, "automation", model.BackgroundJobStatusQueued).Count(&jobCount).Error; err != nil { + t.Fatalf("count jobs: %v", err) + } + if jobCount != 1 { + t.Fatalf("expected one csat template job, got %d", jobCount) + } + processed, err := wp.ProcessOne(context.Background()) + if err != nil || !processed { + t.Fatalf("process csat template job: processed=%v err=%v", processed, err) + } + + var completed model.CsatTemplate + if err := db.First(&completed, template.ID).Error; err != nil { + t.Fatalf("load completed template: %v", err) + } + if completed.Status != "PENDING" { + t.Fatalf("expected provider status PENDING, got %s", completed.Status) + } + var reloadedInbox model.Inbox + if err := db.First(&reloadedInbox, inbox.ID).Error; err != nil { + t.Fatalf("load inbox: %v", err) + } + if !strings.Contains(reloadedInbox.CsatConfig, "csat_survey_1") || !strings.Contains(reloadedInbox.CsatConfig, "tmpl_123") { + t.Fatalf("expected inbox csat_config to include provider template metadata, got %s", reloadedInbox.CsatConfig) + } + if provider.calls != 1 { + t.Fatalf("expected one provider call, got %d", provider.calls) + } +} + +func TestCsatTemplateService_TwilioTemplateStoresContentSid(t *testing.T) { + db := setupServiceTestDB(t) + repo := repository.NewCsatTemplateRepo(db) + svc := NewCsatTemplateService(repo) + svc.SetProvider(&fakeCsatTemplateProvider{result: &CsatTemplateProviderResult{TemplateExists: true, Status: "PENDING", FriendlyName: "csat_survey_2", ContentSID: "HX123", ApprovalSID: "HA123", Language: "en"}}) + account := createTestAccount(t, db) + inbox := createTestInbox(t, db, account.ID, string(model.InboxChannelTypeTwilioSMS), func(inbox *model.Inbox) { + inbox.ChannelConfig = `{"medium":"whatsapp"}` + }) + + template, err := svc.CreateTemplate(context.Background(), inbox.ID, CreateCsatTemplateRequest{Message: "Rate us over WhatsApp"}) + if err != nil { + t.Fatalf("create twilio template: %v", err) + } + if template.Status != "PENDING" { + t.Fatalf("expected sync provider status PENDING, got %s", template.Status) + } + var reloadedInbox model.Inbox + if err := db.First(&reloadedInbox, inbox.ID).Error; err != nil { + t.Fatalf("load inbox: %v", err) + } + if !strings.Contains(reloadedInbox.CsatConfig, "HX123") || !strings.Contains(reloadedInbox.CsatConfig, "HA123") { + t.Fatalf("expected twilio content/approval SIDs in csat_config, got %s", reloadedInbox.CsatConfig) + } +} + +func TestCsatTemplateService_TemplateJobRetriesProviderFailures(t *testing.T) { + db := setupServiceTestDB(t) + repo := repository.NewCsatTemplateRepo(db) + svc := NewCsatTemplateService(repo) + svc.SetProvider(&fakeCsatTemplateProvider{err: errors.New("provider unavailable")}) + now := time.Date(2026, 6, 5, 22, 30, 0, 0, time.UTC) + wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return now }), worker.WithBackoff(func(attempt int) time.Duration { return time.Minute })) + svc.SetWorkerPool(wp) + account := createTestAccount(t, db) + inbox := createTestInbox(t, db, account.ID, string(model.InboxChannelTypeWhatsApp)) + + template, err := svc.CreateTemplate(context.Background(), inbox.ID, CreateCsatTemplateRequest{Message: "Please rate our support"}) + if err != nil { + t.Fatalf("enqueue template create: %v", err) + } + processed, err := wp.ProcessOne(context.Background()) + if err == nil || !processed { + t.Fatalf("expected provider failure to retry, processed=%v err=%v", processed, err) + } + var job model.BackgroundJob + if err := db.Where("job_type = ?", TaskTypeCsatTemplateCreate).First(&job).Error; err != nil { + t.Fatalf("load template job: %v", err) + } + if job.Status != model.BackgroundJobStatusRetrying || job.LastError == "" { + t.Fatalf("expected retrying template job with error, got status=%s last_error=%q", job.Status, job.LastError) + } + var failed model.CsatTemplate + if err := db.First(&failed, template.ID).Error; err != nil { + t.Fatalf("load failed template: %v", err) + } + if failed.Status != "failed" { + t.Fatalf("expected failed template status, got %s", failed.Status) + } +} diff --git a/internal/service/service_test_helper.go b/internal/service/service_test_helper.go index da18c83c..0d2a2967 100644 --- a/internal/service/service_test_helper.go +++ b/internal/service/service_test_helper.go @@ -69,6 +69,7 @@ func setupServiceTestDB(t *testing.T) *gorm.DB { &model.Tag{}, &model.ContactLabel{}, &model.ContactExport{}, + &model.CsatTemplate{}, &model.DataImport{}, &model.Notification{}, &model.BackgroundJob{},