diff --git a/.gitignore b/.gitignore index 8f47d9fc..d783648d 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ vendor/ # Air temp files tmp/ +.tmp/ build-errors.log *.out diff --git a/cmd/gochat/main.go b/cmd/gochat/main.go index fb8783c0..3808106d 100644 --- a/cmd/gochat/main.go +++ b/cmd/gochat/main.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "errors" "fmt" "os" "strings" @@ -69,6 +70,7 @@ func seed() error { if err != nil { return fmt.Errorf("config load failed: %w", err) } + cfg.Log.Level = "silent" if err := applogger.Init(applogger.Config{Level: cfg.Log.Level, Format: cfg.Log.Format, Output: "stdout", ErrorOutput: "stderr"}); err != nil { return fmt.Errorf("logger init failed: %w", err) } @@ -113,6 +115,8 @@ type smokeSeedSummary struct { InboxID uint `json:"inbox_id"` ContactID uint `json:"contact_id"` CompanyID uint `json:"company_id"` + PortalID uint `json:"portal_id"` + ArticleID uint `json:"article_id"` ConversationID uint `json:"conversation_id"` ConversationDisplayID uint `json:"conversation_display_id"` ConversationUID string `json:"conversation_uuid"` @@ -145,10 +149,10 @@ func seedSmokeData(ctx context.Context, db *gorm.DB) (*smokeSeedSummary, error) } now := time.Now() admin := &model.User{} - if err := db.WithContext(ctx).Where("email = ?", adminEmail).FirstOrCreate(admin, model.User{AccountID: account.ID, Name: adminName, DisplayName: adminName, Email: adminEmail, Password: hashed, PasswordDigest: hashed, Provider: "email", Role: "super_admin", Type: "User", Active: true, Available: true, ConfirmedAt: &now, UISettings: datatypes.JSON([]byte(`{}`)), CustomAttributes: datatypes.JSON([]byte(`{}`))}).Error; err != nil { + if err := firstOrCreateBy(ctx, db, admin, model.User{Email: adminEmail}, model.User{AccountID: account.ID, Name: adminName, DisplayName: adminName, Email: adminEmail, Password: hashed, PasswordDigest: hashed, Provider: "email", Role: "super_admin", Type: "User", Active: true, Available: true, ConfirmedAt: &now, UISettings: datatypes.JSON([]byte(`{}`)), CustomAttributes: datatypes.JSON([]byte(`{}`))}); err != nil { return nil, fmt.Errorf("seed admin user: %w", err) } - if err := db.WithContext(ctx).Model(admin).Updates(map[string]any{"account_id": account.ID, "password": hashed, "password_digest": hashed, "active": true, "available": true}).Error; err != nil { + if err := db.WithContext(ctx).Model(admin).Updates(map[string]any{"account_id": account.ID, "name": adminName, "display_name": adminName, "password": hashed, "password_digest": hashed, "provider": "email", "role": "super_admin", "type": "User", "active": true, "available": true, "confirmed_at": now}).Error; err != nil { return nil, fmt.Errorf("update admin user: %w", err) } @@ -173,6 +177,32 @@ func seedSmokeData(ctx context.Context, db *gorm.DB) (*smokeSeedSummary, error) if err := db.WithContext(ctx).Where("account_id = ? AND name = ?", account.ID, "Smoke Company").FirstOrCreate(company, model.Company{AccountID: account.ID, Name: "Smoke Company", Domain: "gochat.local", WebsiteURL: "https://gochat.local", CustomAttributes: datatypes.JSON([]byte(`{"tier":"enterprise"}`))}).Error; err != nil { return nil, fmt.Errorf("seed company: %w", err) } + if err := db.WithContext(ctx).Model(company).Updates(map[string]any{"domain": "gochat.local", "website_url": "https://gochat.local", "custom_attributes": datatypes.JSON([]byte(`{"tier":"enterprise"}`))}).Error; err != nil { + return nil, fmt.Errorf("update smoke company: %w", err) + } + + portalSlug := fmt.Sprintf("gochat-smoke-portal-%d", account.ID) + portal := &model.Portal{} + if err := db.WithContext(ctx).Where("account_id = ? AND slug = ?", account.ID, portalSlug).FirstOrCreate(portal, model.Portal{AccountID: account.ID, Name: "Smoke Help Center", Slug: portalSlug, Description: "B12 frontend smoke help center", HeaderText: "How can we help?", PageTitle: "Smoke Help Center", Color: "#1f93ff", Locale: "en", PortalConfiguration: json.RawMessage(`{"allowed_locales":["en"],"default_locale":"en"}`)}).Error; err != nil { + return nil, fmt.Errorf("seed portal: %w", err) + } + if err := db.WithContext(ctx).Model(portal).Updates(map[string]any{"name": "Smoke Help Center", "description": "B12 frontend smoke help center", "locale": "en"}).Error; err != nil { + return nil, fmt.Errorf("update smoke portal: %w", err) + } + + category := &model.Category{} + if err := db.WithContext(ctx).Where("account_id = ? AND portal_id = ? AND slug = ?", account.ID, portal.ID, "smoke-guides").FirstOrCreate(category, model.Category{AccountID: account.ID, PortalID: portal.ID, Name: "Smoke Guides", Slug: "smoke-guides", Description: "B12 frontend smoke guides", Locale: "en", Position: 1, CustomAttributes: json.RawMessage(`{}`)}).Error; err != nil { + return nil, fmt.Errorf("seed category: %w", err) + } + + articleSlug := fmt.Sprintf("gochat-smoke-onboarding-%d", account.ID) + article := &model.Article{} + if err := db.WithContext(ctx).Where("account_id = ? AND slug = ?", account.ID, articleSlug).FirstOrCreate(article, model.Article{AccountID: account.ID, PortalID: portal.ID, CategoryID: &category.ID, AuthorID: &admin.ID, Title: "Smoke Onboarding Guide", Slug: articleSlug, Description: "B12 frontend smoke article", Content: "Use this onboarding guide to verify Woochat search direct-connect behavior.", Status: "published", Position: 1, Locale: "en", Meta: json.RawMessage(`{}`), CustomAttributes: json.RawMessage(`{}`)}).Error; err != nil { + return nil, fmt.Errorf("seed article: %w", err) + } + if err := db.WithContext(ctx).Model(article).Updates(map[string]any{"portal_id": portal.ID, "category_id": category.ID, "author_id": admin.ID, "title": "Smoke Onboarding Guide", "description": "B12 frontend smoke article", "content": "Use this onboarding guide to verify Woochat search direct-connect behavior.", "status": "published", "locale": "en"}).Error; err != nil { + return nil, fmt.Errorf("update smoke article: %w", err) + } contact := &model.Contact{} if err := db.WithContext(ctx).Where("account_id = ? AND email = ?", account.ID, "customer@gochat.local").FirstOrCreate(contact, model.Contact{AccountID: account.ID, CompanyID: &company.ID, Name: "Smoke Customer", Email: "customer@gochat.local", Identifier: "gochat-smoke-customer", ContactType: "lead", AdditionalAttributes: datatypes.JSON([]byte(`{}`)), CustomAttributes: datatypes.JSON([]byte(`{"plan":"enterprise"}`))}).Error; err != nil { @@ -238,7 +268,7 @@ func seedSmokeData(ctx context.Context, db *gorm.DB) (*smokeSeedSummary, error) if conversation.DisplayID != nil { conversationDisplayID = *conversation.DisplayID } - return &smokeSeedSummary{AdminEmail: adminEmail, AdminPassword: adminPassword, AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, CompanyID: company.ID, ConversationID: conversation.ID, ConversationDisplayID: conversationDisplayID, ConversationUID: conversation.UUID, CsatMessageID: csatMessage.ID, SlaPolicyID: sla.ID, CustomRoleID: customRole.ID, CapacityPolicyID: capacity.ID, CaptainAssistantID: assistant.ID}, nil + return &smokeSeedSummary{AdminEmail: adminEmail, AdminPassword: adminPassword, AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, CompanyID: company.ID, PortalID: portal.ID, ArticleID: article.ID, ConversationID: conversation.ID, ConversationDisplayID: conversationDisplayID, ConversationUID: conversation.UUID, CsatMessageID: csatMessage.ID, SlaPolicyID: sla.ID, CustomRoleID: customRole.ID, CapacityPolicyID: capacity.ID, CaptainAssistantID: assistant.ID}, nil } func seedMessage(ctx context.Context, db *gorm.DB, conversation *model.Conversation, inboxID, senderID uint, messageType, contentType, content string) (*model.Message, error) { @@ -264,6 +294,22 @@ func seedMessage(ctx context.Context, db *gorm.DB, conversation *model.Conversat return message, nil } +func firstOrCreateBy[T any](ctx context.Context, db *gorm.DB, dest *T, query T, attrs T) error { + result := db.WithContext(ctx).Where(query).First(dest) + if result.Error == nil { + return nil + } + if !errors.Is(result.Error, gorm.ErrRecordNotFound) { + return result.Error + } + record := attrs + if err := db.WithContext(ctx).Create(&record).Error; err != nil { + return err + } + *dest = record + return nil +} + func getenvDefault(key, fallback string) string { if value := strings.TrimSpace(os.Getenv(key)); value != "" { return value diff --git a/cmd/reindex_search/main.go b/cmd/reindex_search/main.go index b8ff585e..6b5c0c90 100644 --- a/cmd/reindex_search/main.go +++ b/cmd/reindex_search/main.go @@ -204,8 +204,6 @@ func reindexContacts(ctx context.Context, engine search.SearchEngine, db *gorm.D for { var rows []model.Contact q := db.WithContext(ctx). - Preload("Portal"). - Preload("Category"). Where("id > ?", lastID).Order("id ASC").Limit(batchSize) if accountID != 0 { q = q.Where("account_id = ?", accountID) diff --git a/configs/config.yaml b/configs/config.yaml index 6929973f..05c82139 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -8,8 +8,8 @@ server: # - "https://app.example.com" # - "*.example.com" allowed_methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] - allowed_headers: ["Origin", "Content-Type", "Accept", "Authorization", "X-Account-ID"] - expose_headers: ["Content-Length"] + allowed_headers: ["Origin", "Content-Type", "Accept", "Authorization", "X-Account-ID", "access-token", "client", "uid", "token-type", "expiry"] + expose_headers: ["Content-Length", "access-token", "client", "uid", "token-type", "expiry"] allow_credentials: false # set to true only if you need cookies/auth headers max_age: 86400 # preflight cache duration in seconds @@ -31,6 +31,7 @@ redis: port: 6379 password: "" db: 0 + pool_size: 50 jwt: secret: "gochat_dev_secret_change_in_production" diff --git a/docs/comparison/2026-06-11-gochat-chatwoot-woochat-alignment-plan.md b/docs/comparison/2026-06-11-gochat-chatwoot-woochat-alignment-plan.md new file mode 100644 index 00000000..6e991f31 --- /dev/null +++ b/docs/comparison/2026-06-11-gochat-chatwoot-woochat-alignment-plan.md @@ -0,0 +1,211 @@ +# GoChat ↔ Chatwoot/Woochat Frontend Alignment Review And Plan + +> Date: 2026-06-11 +> Scope: review implementation differences between GoChat and `reference/chatwoot`, with Woochat/current Chatwoot frontend direct-connect compatibility as the acceptance target. +> Output: gap list, priority order, and executable alignment plan for the refactored GoChat business service. +> Status: Live enterprise browser smoke, route parity, Meilisearch search smoke, and full Go regression passed against the reused Chatwoot frontend; notification list/profile-preference browser-store smoke, provider callback/setup fixtures, Captain provider-mode fixtures, widget pubsub-token realtime fixtures, account actions contact-merge fixtures, agent-bot account route fixtures, assignable-agent query fixtures, assignment-policy fixtures, agent-capacity nested route fixtures, agent create fixtures, custom-attribute definition fixtures, audit-log fixtures, automation-rule fixtures, campaign fixtures, canned-response fixtures, bulk-action fixtures, Captain/Copilot API fixtures, core channel provider frontend fixtures, voice/WhatsApp setup fixtures, CRM company/contact/note fixtures, CSAT report fixtures, custom-role/custom-filter fixtures, dashboard-app fixtures, `dashboard/api/endPoints.js` auth/profile literal fixtures, help-center dashboard fixtures, inbox conversation/message fixtures, inbox health/member/inbox fixtures, SAML settings fixtures, summary reports fixtures, generic integrations fixtures, Dyte/Linear/Slack literal integration runtime fixtures, labels/macros/notifications/reports/search/SLA literal runtime fixtures, teams/summary-reports/user-notification-settings/webhooks/year-in-review/CSAT public fixtures, widget literal runtime fixtures, account literal fixtures, agent-capacity literal fixtures, agent bulk-create literal triage, article literal triage, automation literal triage, company literal runtime paths, contacts literal runtime paths, conversations literal label/unread-count paths, `endPoints.spec.js` legacy constants, CSAT reports literal runtime paths, and inbox conversation literal runtime paths and inbox message literal runtime path and inbox literal runtime paths are now covered. `docs/parity/frontend_contract_inventory.md` now has 0 rows marked `Mapped; needs fixture/smoke evidence`; final release gates were rerun on 2026-06-13 and passed: route parity, full Go regression, and live enterprise Woochat/browser smoke. + +## Executive Summary + +- The reused Woochat frontend contract is represented by `reference/chatwoot/app/javascript/dashboard/api`, `reference/chatwoot/app/javascript/widget/api`, `reference/chatwoot/app/javascript/survey/api`, and their dependent stores/composables. +- Current GoChat has strong route coverage for the tracked frontend-critical surface: refreshed route parity reports `435 exact`, `0 method-compatible`, `9 parameter-compatible`, and `0 missing` out of `444` tracked critical routes. +- The direct-connect blockers found in this pass were fresh-schema omissions, Chatwoot token-header/CSRF/CORS assumptions, Redis pool starvation under smoke load, trailing-slash collection-route redirects, Vite browser-smoke shell routing, dashboard boot feature gates, and missing fresh-schema team/campaign tables. These have been aligned for the covered dashboard/widget/public/enterprise API and browser paths. +- No independent `/home/rogee/Projects/*woochat*` checkout was found during this review, so this plan treats `reference/chatwoot` as the current Woochat frontend source of truth. +- This document now records the landed alignment work and the current completion evidence. Keep realtime and provider fixtures as drift guards, but the direct-connect release gates have current evidence. + +## Evidence Refresh + +| Evidence | Current Result | Notes | +| --- | --- | --- | +| Route dump | `docs/parity/gochat_routes.txt` refreshed from `go run ./cmd/dump_routes` | Current dump contains `1010` lines including `TOTAL: 1009`. | +| Route parity | `docs/parity/route_parity.md` refreshed from `go run ./cmd/route_parity` | `435 exact`, `0 method-compatible`, `9 parameter-compatible`, `0 missing` out of `444` tracked critical routes. | +| Frontend contract scan | `reference/chatwoot/app/javascript/dashboard/api`, `widget/api`, `survey/api` | Dashboard, widget, public survey, Captain, enterprise, channel, report, and integration clients define the direct-connect API contract. | +| Frontend contract inventory | `docs/parity/frontend_contract_inventory.md` | Phase 0 inventory created with `224` frontend API contract entries grouped by owner area. | +| Live enterprise browser smoke | `docs/parity/frontend_smoke_report.md`; `.tmp/frontend-smoke-live/browser-smoke-report.json` | Passed on 2026-06-13T12:24:17Z with 37 checks, 0 failures, reused `reference/chatwoot` dashboard/widget/enterprise screens against GoChat on port `13000`. | +| Full Go regression | `git diff --check && GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./...` | Passed on 2026-06-13 after smoke-script stabilization and route parity refresh. | +| Parameter-compatible route tests | `internal/handler/api/v1/agent_capacity_handler_test.go` and `internal/handler/api/v1/article_handler_test.go` | Agent-capacity nested user/inbox-limit methods and help-center `.md`/`.png` suffix dispatch are covered by focused handler tests. | +| Auth/profile/account fixtures | `internal/handler/api/v1/auth_handler_test.go`, `internal/handler/api/v1/profile_handler_test.go`, `internal/handler/api/v1/mfa_handler_test.go`, `internal/handler/api/v1/account_handler_test.go`, `internal/repository/account_repo_test.go` | Sign-in, validate-token, profile bootstrap, availability, auto-offline, active-account side effects, MFA raw payloads, and account cache keys now assert Chatwoot-style frontend-visible keys and persisted side effects. | +| Inbox fixtures | `internal/handler/api/v1/inbox_handler_parity_test.go`, `internal/handler/api/v1/inbox_member_handler_test.go`, `internal/handler/api/v1/agent_capacity_handler_test.go`, `internal/handler/api/v1/agent_bot_handler_test.go` | Inbox list/detail envelopes, channel settings, admin-only sensitive fields, inbox member payload/diff updates, raw WhatsApp Cloud health payloads, agent create payloads, assignable-agent `inbox_ids` queries, assignment-policy list/inbox override flows, agent-capacity policy/nested user/inbox-limit success/error flows, agent-bot account CRUD/avatar/reset routes, Facebook/Google/Instagram/Microsoft/TikTok/Twilio/Twitter/Web/WhatsApp-call provider frontend routes, and provider callback/webhook drift guards now have frontend-contract fixtures. | +| Conversation/message fixtures | `internal/handler/api/v1/conversation_handler_crud_test.go`, `internal/handler/api/v1/message_handler_test.go`, `internal/handler/api/v1/conversation_serializer_test.go` | Conversation list envelope/meta/label/attribute/last-message fields and message create/list/attachment fields now assert the Chatwoot dashboard store contract. | +| CRM/contact/company fixtures | `internal/handler/api/v1/crm_frontend_smoke_test.go`, `contact_handler_crud_test.go`, `company_handler_test.go` | Contact/company dashboard smoke plus account actions contact merge, contact merge/import/export, contact notes, company nested contacts/conversations/notes, contact timeline attachments, contact/company update realtime events, contact delete realtime events, and merge update/delete realtime events now assert frontend-visible response and mutation contracts. | +| Voice/WhatsApp/CSAT/customization fixtures | `internal/router/router_test.go`, `internal/service/whatsapp_authorization_service_test.go`, `internal/handler/api/v1/csat_survey_handler_test.go`, `custom_role_handler_test.go`, `custom_filter_handler_test.go`, `dashboard_app_handler_test.go` | Woochat voice conference callbacks/contact call initiation, WhatsApp embedded signup/reauthorization, CSAT dashboard list/metrics/download, custom roles, custom filters, and dashboard apps now have direct frontend-contract evidence in the inventory and focused command list. | +| Contract fixture coverage | `docs/parity/contract_fixture_coverage.md` | P0/P1 owner areas now have a coverage matrix mapping frontend contract source, current GoChat evidence, and remaining evidence needed; auth/profile/account has focused fixture coverage for sign-in/profile/MFA/cache slices, while remaining avatar/accounts/conversations literals stay in the inventory backlog. | +| Regression test | `git diff --check` and `GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./...` passed again on 2026-06-13 after the Captain/provider/widget evidence updates and route regeneration. | Covers the expanded direct-connect fixture suite, route boot, webhook receipt updates, service jobs, widget/API handlers, middleware, repository, router, and websocket packages. | +| Placeholder audit | `docs/parity/placeholder_audit.md` | No `chatwootParityStub` remains on reused critical dashboard/widget/public paths; provider nil-fallbacks return explicit `503`. | +| Frontend smoke harness | `docs/parity/frontend_smoke_report.md`, `scripts/parity_frontend_smoke.sh`, and `scripts/parity_frontend_browser_smoke.mjs` | Fresh PostgreSQL/Redis/Meilisearch migration, seed, API smoke, enterprise API smoke, and live `scripts/parity_frontend_smoke.sh --enterprise-browser-smoke` passed on 2026-06-13 against GoChat on `127.0.0.1:13000` and reused Chatwoot Vite on `localhost:3036`; browser report is `.tmp/frontend-smoke-live/browser-smoke-report.json` with 37 checks and 0 API failures, including notification/profile preference store requests. | + +## Landed Alignment Work In This Pass + +- Added fresh-schema migrations for dashboard apps, extended contacts/conversations/accounts/channel fields, companies, SLA, installation configs, CSAT responses, bot rules, automation rules, macros, macro executions, and audits so a clean PostgreSQL database can migrate and seed without hidden legacy state. +- Made smoke seed idempotent for the admin user and kept seed output parseable for the smoke harness even when bootstrap logs are emitted. +- Aligned Chatwoot direct-auth behavior by allowing DeviseTokenAuth-style `access-token` headers in `AuthMiddleware` and skipping CSRF for token-auth API/public/widget paths that the reused frontend calls. +- Increased Redis pool configurability/default capacity to avoid login/token storage failures under worker plus smoke concurrency. +- Registered both no-slash and trailing-slash collection routes for frontend-critical dashboard and enterprise paths to prevent Gin 301 HTML redirects from breaking JSON clients. +- Exposed and allowed DeviseTokenAuth headers through CORS so the browser can read `access-token`, `client`, `uid`, `token-type`, and `expiry`, persist `cw_d_session_info`, and validate dashboard sessions without frontend changes. +- Hardened browser smoke to serve Chatwoot/Vite shell pages from a same-origin test server, proxy Vite assets, use CDP-captured requests for navigation assertions, send Chatwoot auth headers for browser-context Copilot requests, and fail on frontend-visible API 4xx/5xx responses. +- Added fresh-schema `teams`, `team_members`, and `campaigns` migrations and made the dashboard portal list readable without a knowledge-base feature flag so boot-time unconditional frontend requests do not fail. +- Extended smoke coverage through dashboard auth/profile/inbox/conversation/message/contact/company, widget config/message, public CSAT, SLA, CSAT reports/download, automation, macros, audit logs, custom roles, agent capacity, Captain, and Copilot. +- Added auth/profile/account contract fixtures for DeviseTokenAuth sign-in and validate-token payloads, profile bootstrap user/account keys, availability and auto-offline mutations, `set_active_account` membership `active_at` persistence, MFA raw payloads, and account cache keys. +- Added inbox/provider contract fixtures for Chatwoot-style list/detail envelopes, channel settings, admin-sensitive fields, inbox member payload/diff updates, raw WhatsApp Cloud health responses, provider delivery/read/failure callbacks, and duplicate status-callback idempotency. +- Added conversation/message contract fixtures for Chatwoot-style conversation list envelopes, meta sender fields, labels, additional/custom attributes, last-message payloads, outgoing message create defaults, sender payloads, content attributes, attachments, and display conversation IDs. +- Fixed profile serialization to always expose `custom_attributes` as an empty object when unset, matching the reused frontend's object-access assumptions, and made account-user `active_at` updates database-portable instead of relying on PostgreSQL-only `NOW()`. + +## Alignment Target + +Woochat/current Chatwoot frontend must be able to point at GoChat by changing only backend host/runtime configuration, without patching frontend API clients or store logic. + +That requires parity across five layers: + +1. **Route shape**: every frontend-called path/method exists with Chatwoot-compatible path, query, multipart, and auth handling. +2. **Wire contract**: JSON envelopes, pagination metadata, object keys, enum values, null/empty behavior, error status, and validation messages match frontend expectations. +3. **State transitions**: mutations update all dependent models and counters that frontend stores read after success or realtime replay. +4. **Realtime contract**: websocket/SSE/Redis event names and payloads match Chatwoot event consumers for conversation, message, notification, presence, typing, cache, inbox, and contact updates. +5. **Operational side effects**: async jobs, search indexing, delivery status, webhook dispatch, CSAT, reports, and provider integrations are durable enough that the frontend does not observe stale or fake success states. + +## Current Functional Difference Map + +| Priority | Area | Current GoChat State | Chatwoot/Woochat Expectation | Required Alignment | +| --- | --- | --- | --- | --- | +| P0 | Full reused frontend smoke | API, browser, enterprise API, and enterprise browser smoke now pass for the covered route-request paths. | Dashboard, widget, public CSAT/help-center, and enterprise screens run unchanged against GoChat. | Keep the smoke gate in CI/release and convert any future failures into named implementation slices. | +| P0 | Auth/session bootstrap | DeviseTokenAuth-style `access-token` API auth, CSRF skip paths, browser CORS header exposure, sign-in/validate-token/profile user-account fixtures, availability, auto-offline, active-account persistence, MFA raw payloads, and cache keys are now aligned for direct API and browser calls. | Login, validate-token, profile, account cache bootstrap, MFA-visible fields, active account, and token refresh behave like Chatwoot without frontend client changes. | Keep auth/profile/account fixtures as drift guards while moving to inbox and conversation fixture slices. | +| P0 | Serializer/envelope drift | Auth/profile/account, inbox list/detail/member/health, and core conversation/message fixtures now guard key frontend-visible payloads, but CRM, widget/public, reports, enterprise, and deeper mutation rows still need frozen fixtures. | Frontend stores consume exact Chatwoot object shapes, pagination, meta, and error envelopes. | Freeze representative fixture responses from Chatwoot and compare GoChat for contacts, reports, widget, public, enterprise screens, and remaining conversation mutations. | +| P0 | Route parameter compatibility | No tracked route is missing, but 9 are parameter-compatible rather than exact. | Frontend-visible literal paths must work; server-side parameter names are not visible, but suffix dispatchers must preserve `.md`/`.png` behavior. | Agent-capacity nested users/inbox-limits and help-center article `.md`/`.png` routes now have focused proof; keep future parameter-compatible routes covered by external-path tests. | +| P0 | Realtime payload fidelity | Event constants, publisher, WS/SSE delivery, message/conversation mutation fixtures, typing/presence heartbeat and expiry fixtures, notification/cache fixtures, widget `pubsub_token` room message delivery fixtures, browser subscription create/destroy, user notification preference update fixtures, and live browser requests for the notification list/profile notification-preference screens exist for Chatwoot-style frontend behavior. | Frontend stores expect `message.created`, `conversation.updated`, `notification.created`, typing/presence/cache events with Chatwoot payload fields; PushHelper and notification-preference screens expect raw Chatwoot payloads and account-scoped routes; widget ActionCable expects `message.created` on the contact `pubsub_token` room. | Keep notification/preference and widget browser smoke as drift guards. | +| P0 | Fresh database bootstrap | Fresh-schema migrations and smoke seed were extended for dashboard apps, CRM, teams, campaigns, SLA, CSAT, automation, macros, audits, Captain, and account/inbox fields. | A new deployment can migrate, seed, sign in, and load Woochat frontend screens without relying on hidden legacy rows or manual SQL backfills. | Keep migration smoke as a release gate and add rollback/idempotency checks for the new Chatwoot-compatible schema slices. | +| P1 | Conversation/message behavior | Core services, serializers, labels, participants, drafts, attachments, status/assignment paths exist. | Finder filters, display IDs, unread counts, assignee/team/status transitions, private notes, reply-to, delete/retry/update, attachment payloads, and mentions match Chatwoot. | Add contract tests around conversation list/detail/search/message create-update-delete and verify frontend store refresh/realtime paths. | +| P1 | Inbox/channel/assignment setup | Inbox, member, assignable-agent, assignment-policy, agent-capacity, channel settings, health raw payload, provider-failure envelope, and assignment-policy inbox override routes are represented. | Inboxes and channels expose Chatwoot-compatible settings, members, availability, provider health/failure, assignment-policy override, and capacity payloads. | Add signed provider setup/callback fixtures beyond WhatsApp health and keep role-sensitive channel settings as drift guards. | +| P1 | CRM/contact/company | Contact/company handlers, custom attributes, notes, labels, search, and frozen dashboard payload-shape fixtures are represented for common contact/company flows; contact merge/import/export contracts, attachment timeline depth, contact/company realtime update payloads, and contact delete/merge source-side events now have focused fixture coverage. | Contact list/detail, contact inboxes, merge, import/export, company contacts, notes, custom attributes, labels, realtime updates, and timeline fields match dashboard stores. | Keep CRM fixtures as drift guards; contact update/delete/merge realtime source paths are now covered. | +| P1 | Widget/public inbox APIs | Widget routes, messages, campaigns, direct uploads, contact update, public inbox paths, focused widget config/pre-chat/message/ActiveStorage direct-upload/campaign/public contact/status/read/labels/events/realtime payload fixtures, `cw_conversation` query/cookie reuse fixtures, and expanded browser-smoke harness coverage for widget boot now exist. | Widget API accepts Chatwoot query params (`website_token`, `locale`, `cw_conversation`, `cw_d_session_info`), persisted `cw_conversation` cookies, multipart attachments, ActiveStorage direct uploads, campaigns, labels/events, update/read semantics, widget `message.created` pubsub-token realtime delivery, and reused widget entrypoint boot requests. | Keep live browser smoke and widget realtime fixtures as drift guards. | +| P1 | Search and indexing | Meilisearch-first engine, DB fallback, indexers, search handlers, seed-created help-center article data, live search API-smoke assertions for seeded conversation/message/contact/company/article payloads, valid Meilisearch document UIDs, explicit `uid` primary-key writes, and company/article mutation hook fresh-object tests exist. | Search results for conversations, messages, contacts, companies, and articles are account-scoped, fresh after mutations, and shaped for frontend global/search modules. | Keep live Meilisearch smoke, reindex job, and fallback rejection in release mode as release gates. | +| P1 | Help-center/public portal | Portal/category/article/search/sitemap routes exist, including dashboard article CRUD, JSON, markdown, and tracking-pixel compatibility paths; dashboard create/list/show/edit/update/delete payloads, locale redirect/fallback, JSON list/filter, sitemap, `.md`, and `.png` fixtures now assert Chatwoot-compatible behavior. | Public help center and dashboard article management support locale redirects, article/category listing, search, sitemap, `.json`, `.md`, `.png` tracking behavior, and Chatwoot dashboard article CRUD payloads. | Keep as drift guard; add browser smoke only if Woochat portal UI changes. | +| P1 | Direct uploads and attachments | Account/widget direct upload handlers and attachment serializers exist; ActiveStorage create/complete response fields, storage bytes, signed IDs/upload UUIDs, widget attachment payloads, and unsupported MIME validation now have focused fixtures. | Chatwoot frontend can upload files through ActiveStorage-style direct-upload flow and send attachments in dashboard/widget messages. | Add provider send-payload and REST-vs-push attachment drift guards where channel delivery paths serialize attachments. | +| P1 | Automation/macros/campaigns | Automation rules, macros, macro executions, bot rules, campaigns, and canned responses are represented; automation-rule CRUD/clone/toggle validation and campaign lifecycle/trigger side effects, scheduled trigger job dispatch/realtime events, and bulk-action progress now have focused Chatwoot frontend contract fixtures, with service alignment for campaign display-id start/stop plus frontend audience-array trigger tolerance. | Dashboard automation-rule create/list/update/clone/toggle/delete, campaign create/update/start/stop, macro execute, and canned response CRUD/search flows return Chatwoot-compatible payloads or explicit action envelopes and mutate conversations/messages where applicable, including campaign-triggered conversation/message creation, scheduled campaign trigger job dispatch/completion, campaign trigger conversation/message realtime events, macro empty-200 execute response/validation behavior, and durable bulk-action completion/search-index side effects. | Keep this slice as a drift guard; re-open only if Woochat starts consuming a non-empty macro execute response body. | +| P1 | Notifications/preferences | Notification, notification-subscription, user notification settings, presence, and account cache routes exist; notification CRUD/read-state, PushHelper browser subscription create/destroy, user preference show/update persistence, source-side mutation publication, notification/cache realtime payload fixtures, and live browser route requests for `/notifications?page=1` plus `/notification_settings` are covered. | Notification lists, read/unread transitions, browser subscription state, user notification preferences, and online status stay in sync with frontend stores. | Keep as drift guard; broaden only if Woochat changes subscription/preference UI behavior or adds new notification-store calls. | +| P1 | Enterprise screens | SLA, audit logs, custom roles, agent capacity, assignment policies, Captain/Copilot, CSAT, limits and account enterprise routes pass API and browser route-request smoke; `TestChatwootPermissionSetCreateUpdateListShowParity` freezes Chatwoot's six custom-role permission keys; `TestEnterpriseAccountLiteralFrontendPathsUseCurrentAccountContext` covers literal enterprise account API paths; `TestCaptainAssistantHandler_PlaygroundV2ProviderErrorReturnsChatwootFallback` freezes Chatwoot's Captain V2 provider-error fallback shape; `TestCopilotResponseJobForFollowupMessageUsesStoredThreadHistoryAndIdempotency` covers background Copilot follow-up jobs. | Enterprise frontend screens load and mutate without frontend patches or fake success states, including custom-role permission management, enterprise account limits/billing actions, Captain limit availability clamping, subscription existing-customer no-op behavior, Captain V2 provider-error `conversation_handoff` fallback, and Copilot follow-up response jobs through stored thread history. | Keep live provider smoke and fixture drift guards as release gates. | +| P1 | Reports/live reports | API v2 reports and summary report routes exist; `TestAPIV2Reports_ChatwootPayloadShapes`, `TestAPIV2Reports_AllFrontendEndpointShapes`, `TestAPIV2Reports_TimeseriesTimezoneValueParity`, and `TestAPIV2Reports_CSVDownloadEntrypointsMatchChatwootFrontend` freeze summary, timeseries, timezone-offset day grouping values, CSV download, matrix, bot, distribution, conversation, and outgoing-count endpoint shapes; `TestAPIV2LiveReports_ChatwootFrontendPayloadShapes` freezes v2 live-report conversation/grouped payloads; `TestAPIV2LiveReports_StoreRefreshSequenceMatchesChatwootFrontend` covers the three Chatwoot liveReports store refresh actions; `TestAPIV2LiveReportsRouterAuthAndAccountScope` covers live-report auth/account-scope enforcement; audit list serializer filters, fixed page size, pagination meta, and descending page order have fixture coverage. | Dashboard reports have focused frontend-direct-connect fixtures for API v2 report shape, value, CSV, live payload, auth, and store-refresh paths. | Keep reports fixtures as drift guards; broaden only if Woochat adds new report API calls. | +| P1 | CSAT/survey | Public CSAT and dashboard CSAT list/metrics/download paths are represented and included in API smoke; `TestMetrics_DashboardValueDriftFiltersMatchChatwootFrontend` freezes Chatwoot's response-filtered `total_count`/`ratings_count` and account/date-only `total_sent_messages_count`; review-note audit/serializer and public PUT/locked-update fixtures now cover the frontend-visible mutation edges. | Public survey show/update plus dashboard metrics/download match Chatwoot status codes, validation errors, CSV/export payloads, date filters, dashboard metric filters, sent-count semantics, review-note audit effects, and public PUT/locked non-mutation behavior. | Keep status-transition/job-send fixtures as follow-up drift guards. | +| P2 | Provider integrations | Webhook/channel handlers now have fixtures for WhatsApp delivered/read/failed, Twilio delivered/read/failed plus duplicate status idempotency, Facebook/Instagram delivered/read receipts plus retry idempotency, TikTok read receipts plus retry idempotency, LINE valid/missing/invalid signature handling, Telegram webhook setup success/failure reauthorization flags, setup payloads, OAuth callback fallback/upsert edges, email OAuth token payload errors, and Linear/Shopify/Notion provider-error redirects without phantom hooks. | Frontend-visible channel setup and health states reflect real provider ack/failure/retry behavior. | Run live provider smoke where credentials are available; keep fixtures as drift guards for future provider payload changes. | +| P2 | Background jobs and durability | Worker/dispatch services exist for indexing, delivery, CSAT, import/export, reports, webhooks, Captain, and maintenance. | Chatwoot side effects are asynchronous, retryable, idempotent, and observable when the frontend refreshes or receives events. | Add job idempotency/retry tests and expose frontend-visible failure states instead of silent success. | +| P2 | SSO and identity edge | SSO/SAML/LDAP/OIDC are currently treated as excluded/partially wired in prior tracker text. | Woochat auth screens may still contain MFA/profile/session/token flows even if enterprise SSO is feature-gated. | Document deployment gates for SSO-family features; keep profile, session, MFA, token validation, and active-account behavior fully frontend-compatible. | + +## Required Alignment Function Points + +The remaining alignment work should be tracked as frontend-owner slices, not only as backend packages, because the acceptance target is unchanged Woochat frontend code against GoChat. + +| Owner Slice | Must Align For Direct Frontend Connect | Primary Evidence To Add | +| --- | --- | --- | +| Auth/profile/account bootstrap | `auth.js`, `account.js`, `enterprise/account.js`, `CacheEnabledApiClient.js`, MFA, validate-token, active-account, auto-offline, account cache keys, installation config. | Browser login smoke plus fixture assertions for `/auth/sign_in`, `/auth/validate_token`, `/profile`, `/accounts`, `/accounts/:id`, and enterprise account endpoints. | +| Inbox/channel/assignment | `inboxes.js`, `inboxMembers.js`, `assignableAgents.js`, `assignmentPolicies.js`, `agentCapacityPolicies.js`, channel clients, inbox health. | Fixtures for inbox list/detail/settings/members, provider channel create/update, assignment-policy inbox overrides, assignable-agent availability, capacity users/inbox-limits, and health states. | +| Conversations/messages | `conversations.js`, `inbox/conversation.js`, `inbox/message.js`, conversation/message stores. | Fixtures for list/detail/search/filter/meta, message create/update/delete/retry, private notes, attachments, labels, participants, drafts, assignment/status/team changes, and matching realtime events; update/delete/retry now include Chatwoot frontend route/body assertions plus message-mutation dispatch evidence for external-error, tombstone cleanup, and retry status reset; message.created/message.updated/conversation.updated/conversation.typing_on realtime wire shapes, Chatwoot presence users/contacts payloads, and conversation status/priority/labels/assignment change-data events are fixture-covered; draft collection and participants now resolve Chatwoot display conversation IDs end-to-end; unread/update-last-seen display-ID routes now cover last-seen rollback and notification read side effects. | +| CRM | `contacts.js`, `companies.js`, `contactNotes.js`, `attributes.js`, labels/custom attributes. | Fixtures for contact/company list/detail, merge, notes, contact inboxes, import/export, labels, custom attribute CRUD, contact/company search, attachment timeline, and contact/company realtime updates. | +| Widget/public inbox | `widget/api/*`, public inbox/contact/conversation/message endpoints, direct uploads, campaigns. | Browser widget smoke plus fixtures for config/pre-chat frontend mixin shape, `cw_conversation` cookie/query-session reuse during config boot and message history fetch, message pagination, contact update, labels, events, campaigns, multipart/direct upload, and public conversation status/read flows. | +| Help center/public portal | `helpCenter/*`, `portal/api/article.js`, `/hc` JSON/markdown/tracking/sitemap paths. | Fixtures for locale fallback, portal/category/article listing, article JSON/markdown, search, sitemap, and tracking pixel side effect/content type. | +| Reports/CSAT/live reports | `reports.js`, `summaryReports.js`, `liveReports.js`, `csatReports.js`, `survey/api/*`. | API v2 report endpoint shape fixtures are covered across reports.js index/summary/conversations/agents/inboxes/labels/teams/conversations_summary/conversation_traffic/bot_metrics/bot_summary, timeseries/CSV/matrix/distribution/outgoing-count, and live-report conversation/grouped paths; v2 timezone-offset day grouping value parity covers frontend `timezone_offset` semantics; v2 CSV download entrypoints now cover agents/inboxes/labels/teams/conversations_summary response headers and raw CSV bodies; live-report auth/account-scope enforcement now rejects unauthenticated and cross-account access while accepting Chatwoot `access-token`; live-report store-refresh sequence now covers account/agent/team actions and raw commit-ready payloads; outgoing message count value parity now covers agent/team/inbox/label grouping, invalid `group_by`, date-range exclusion, incoming-message exclusion, and bot sender exclusion for agent grouping; CSAT dashboard list/metrics/download, metric value-drift filters, review-note audit/serializer behavior, and public show/update/locked PUT edges are fixture-covered; keep status-transition/job-send fixtures as drift guards. | +| Automation/macros/campaigns | `automation.js`, `macros.js`, `campaigns.js`, `cannedResponse.js`, bulk actions. | Automation-rule CRUD/clone/toggle validation fixture, campaign nested body/display-id lifecycle, trigger side-effect, scheduled job-dispatch, and realtime event fixtures, macro display-conversation execute side-effect fixture, canned response CRUD/search fixtures, bulk-action enqueue/progress/search-index fixtures, macro empty-200 execute response behavior, and broad bot-rule suites are covered; keep as drift guard unless Woochat changes the macro execute response contract. | +| Notifications/realtime/cache | `notifications.js`, `notificationSubscription.js`, user notification settings, websocket consumers, cache invalidation. | Notification list/read/unread/delete fixtures plus `notification.created`/`notification.updated`/`notification.deleted` payload fixtures using `{ notification, unread_count, count }`; `account.cache_invalidated.cache_keys` label/inbox/team fixture; PubSub subscriber now forwards updated/deleted/cache topics; read/unread/snooze/delete handlers now publish Chatwoot notification store payloads; browser PushHelper subscription create/destroy and user notification settings show/update persistence are fixture-covered. | +| Enterprise/Captain/Copilot | `sla.js`, `customRole.js`, `auditLogs.js`, `captain/*`, enterprise account APIs, limits. | Enterprise browser smoke plus enterprise account literal-path limits/billing actions, Captain limit availability clamping, subscription existing-customer no-op behavior, and billing error no-mutation boundaries, custom-role Chatwoot permission key parity, Captain documents/responses payload and frontend filter fixtures, Captain document response-builder queue/idempotency/FAQ/embedding job fixtures, Captain playground legacy disabled fallback, Captain V2 enabled response and provider-error `conversation_handoff` fallback, Copilot thread provider-disabled/provider-enabled fixtures, background Copilot follow-up job history/idempotency, and SLA inbox association response/DB side effects are covered; keep as drift guard for new Captain/Copilot provider modes. | +| Provider integrations | `channel/*`, `integrations/*`, `webhooks.js`, provider callback routes. | Signed provider callback fixtures, setup/health/delivery-state tests, retry/idempotency checks, and stable unsupported responses for unconfigured providers; WhatsApp signed delivery-status callback now persists delivered/read/failed message statuses, delivery status timestamps, status events, frontend-visible `external_error` for failures, and duplicate same-status callbacks as idempotent no-ops; Twilio delivery-status callbacks now normalize callback phone numbers, persist delivered/read/failed message statuses, delivery status rows, status events, frontend-visible `external_error` for failures, and suppress duplicate same-status callbacks as idempotent no-ops; TikTok read receipts now persist read message status, delivery status, status event, and duplicate read-receipt retries as idempotent no-ops; contact-level read receipts now also backfill per-contact delivery status rows for messages already at the target read status (`TestIncomingPersisterQueuesContactMessagesStatusUpdateWithWorker`); Facebook/Instagram delivery/read receipts now persist delivered/read message statuses, delivery status rows, status events from Chatwoot-compatible webhook endpoints, and duplicate receipt retries as idempotent no-ops; Facebook page setup now covers `facebook_pages.json` provider failure as 422, raw `register_facebook_page` inbox payload/config, and `reauthorize_page` config updates; root Instagram/TikTok/Twitter OAuth callbacks now persist serializer-readable inbox channel config before redirecting Woochat to inbox setup; repeated Google/Instagram/TikTok/Twitter callbacks refresh existing channel/inbox config and redirect to settings instead of the new-inbox agent step, while Google/Microsoft token payload errors redirect to the frontend fallback without creating phantom inboxes; Linear/Shopify/Notion token payload errors redirect to stable Chatwoot frontend destinations without creating phantom hooks; TikTok setup now returns stable `{channel,inbox}` payloads and persists channel config even when provider setup is disabled; Telegram webhook setup success stores `webhook_url`, and setup failure marks `reauthorization_required` instead of silently exposing a working channel; LINE/Twilio setup fixtures assert raw inbox payloads plus persisted channel linkage/config; LINE webhooks now cover valid, missing-signature, and invalid-signature behavior without creating phantom messages. | + +## Parameter-Compatible Route Follow-Up + +These are not missing frontend paths, but they require explicit coverage because route parity is currently shape-compatible instead of exact-name-compatible: + +| Priority | Route Family | Chatwoot Path Shape | GoChat Match | Alignment Action | +| --- | --- | --- | --- | --- | +| P0 | Agent capacity users | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/users` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/users` | Covered by `TestChatwootPolicyInboxLimitAndUserFlow` plus `TestChatwootPolicyInboxLimitAndUserValidationErrors`: create/list/delete response shape and validation/not-found envelopes use the Chatwoot literal frontend path. | +| P0 | Agent capacity inbox limits | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/inbox_limits/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits/:limit_id` | Covered by `TestChatwootPolicyInboxLimitAndUserFlow` plus `TestChatwootPolicyInboxLimitAndUserValidationErrors`: create/duplicate validation/`PUT`/`PATCH`/delete, negative-limit validation, and missing-limit not-found envelopes use the Chatwoot literal frontend path. | +| P0 | Help-center markdown | `/hc/:slug/articles/:article_slug.md` | suffix-compatible article route | Covered by `TestPublicMarkdown_ReturnsOnlyPublishedMarkdown`: verifies `text/markdown; charset=utf-8`, raw body, locale-specific content, and draft 404 behavior. | +| P0 | Help-center tracking pixel | `/hc/:slug/articles/:article_slug.png` | suffix-compatible article route | Covered by `TestPublicTrackingPixel_IncrementsPublishedArticleViews`: verifies `image/png`, 1x1 PNG body, private 24h cache header, published view increment, and draft non-increment behavior. | + +Current evidence: `TestChatwootPolicyInboxLimitAndUserFlow` now covers agent-capacity inbox-limit `POST`/duplicate validation/`PUT`/`PATCH`/`DELETE` and user `POST`/`GET`/`DELETE` flows. `TestChatwootPolicyInboxLimitAndUserValidationErrors` covers negative-limit validation, missing-limit not-found, missing user validation, and missing account-user not-found envelopes on the same Chatwoot literal nested paths. `TestInboxAssignmentPolicy_ChatwootRoutes` covers assignment-policy inbox override attach/show/list/delete. `TestInboxHandler_HealthReturnsWhatsAppCloudRawPayload` and `TestInboxHandler_HealthReturnsProviderFailureState` cover frontend-visible WhatsApp Cloud health success/failure behavior. `TestPublicMarkdown_ReturnsOnlyPublishedMarkdown` and `TestPublicTrackingPixel_IncrementsPublishedArticleViews` cover help-center suffix dispatch behavior. + +## Prioritized Execution Plan + +### Phase 0 — Freeze Frontend Contract + +- Generate an inventory of Woochat frontend API calls from `dashboard/api`, `widget/api`, `survey/api`, relevant stores, and composables. `docs/parity/frontend_contract_inventory.md` now records the first owner-mapped API-client inventory. +- Group each call by owner slice: auth/profile/account, inbox/channel, conversation/message, CRM, automation/macros, help center, widget/public, reports, enterprise, integrations, Captain. +- For each group, record method, path, query/body shape, expected success envelope, expected error envelope, and realtime/cache side effect. +- Deliverable: `docs/parity/frontend_contract_inventory.md` with owner mapping and smoke coverage status. + +### Phase 1 — Close P0 Direct-Connect Gates + +- Run `scripts/parity_frontend_smoke.sh --api-smoke` against PostgreSQL, Redis, Meilisearch, and GoChat. +- Run `scripts/parity_frontend_smoke.sh --browser-smoke` against the reused Chatwoot/Vite frontend. +- Run enterprise smoke modes when enterprise screens are enabled. +- Fix or ticket every failure with a reproducible endpoint, frontend component/store, expected Chatwoot behavior, and GoChat hotspot. +- Acceptance: smoke report records pass/fail by matrix row, no untriaged P0 failure remains. + +### Phase 2 — Contract Fixture Tests + +- Capture or reconstruct Chatwoot-compatible fixtures for key dashboard pages: profile, account cache keys, inbox list/detail, conversation list/detail, message create/update/delete, contact/company, labels/teams, custom filters, reports. +- Add GoChat handler/serializer tests that assert exact keys, enum strings, nullable fields, pagination/meta, timestamps, and errors. +- Include widget/public fixtures for pre-chat, message pagination, direct upload, CSAT submit/update, help-center JSON/markdown/tracking routes. +- Acceptance: fixture tests fail on contract drift and cover all P0/P1 frontend entrypoints. + +### Phase 3 — Realtime And Store Consistency + +- Compare Chatwoot ActionCable event payloads against GoChat WebSocket/SSE/Redis events. +- Verify that every mutation used by the frontend emits the events needed to update unread counts, conversation previews, active timelines, labels, assignments, contact details, notifications, and cache keys. +- Keep browser subscription and user notification settings fixtures as drift guards; the live enterprise browser smoke now also covers the current Chatwoot notification list and profile notification-preference route requests. +- Acceptance: frontend can rely on realtime updates without stale persisted state or manual refresh workarounds. + +### Phase 4 — Side Effects And Async Durability + +- Validate search indexing after every searchable mutation and prove release-mode Meilisearch behavior. +- Validate background jobs for message delivery, delivery status, CSAT sends, contact import/export, webhook dispatch, report rollups, Captain/Copilot jobs, and maintenance cleanup. +- Add idempotency and retry tests where Chatwoot queues jobs; expose frontend-visible failures where provider execution cannot be completed. +- Acceptance: successful API responses correspond to durable side effects or explicit frontend-visible pending/failed states. + +### Phase 5 — Enterprise And Provider Hardening + +- Run enterprise smoke across SLA, custom roles, audit logs, agent capacity, assignment policies, Captain/Copilot, limits, CSAT, automation/macros, and reports. +- Validate channel/provider setup and webhook callbacks with signed fixture requests. +- Document feature gates for intentionally disabled provider/LLM/SSO behavior so the frontend receives stable disabled/unsupported states instead of broken screens. +- Acceptance: enterprise/provider screens either work unchanged or render intentional disabled states from Chatwoot-compatible API responses. + +## Acceptance Matrix + +| Gate | Command Or Evidence | Required Result | +| --- | --- | --- | +| Route parity | `go run ./cmd/dump_routes` then `go run ./cmd/route_parity` | `0 missing`; parameter-compatible route families have focused tests or documented Gin constraints. | +| Static contract | `docs/parity/frontend_contract_inventory.md` and `docs/parity/contract_fixture_coverage.md` | Every frontend API client call is mapped to a GoChat handler/service and smoke/test owner; current inventory has `224` entries and the coverage matrix tracks remaining fixture/smoke evidence. | +| API smoke | `scripts/parity_frontend_smoke.sh --api-smoke` | Auth/profile, inbox, conversation/message, CRM, seeded conversation/message/contact/company/article search, widget, CSAT, and dashboard API checks pass on a fresh PostgreSQL/Redis/Meilisearch stack; latest enterprise-browser run refreshed the company/article search additions. Search captures are written to `search_conversations.json`, `search_messages.json`, `search_contacts.json`, `search_companies.json`, and `search_articles.json`. | +| Browser smoke | `scripts/parity_frontend_smoke.sh --browser-smoke` | Reused Chatwoot/Woochat frontend boots, logs in, validates token, requests conversations, and has no frontend-visible API 4xx/5xx failures in the covered dashboard boot path; latest enterprise-browser report also boots the reused widget entrypoint and passes widget mount/messages/inbox-members/campaigns checks (`.tmp/frontend-smoke-live/browser-smoke-report.json`, 37 passed checks). | +| Enterprise smoke | `scripts/parity_frontend_smoke.sh --enterprise-smoke --no-seed` and `--enterprise-browser-smoke --no-seed` | Enterprise API and enterprise browser route-request smoke pass for SLA, CSAT, automation, macros, audit, custom roles, capacity, Captain, and Copilot. | +| Go tests | `GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./...` | Passed on 2026-06-13 after current code/documentation changes. | +| Diff hygiene | `git diff --check` | Passed on 2026-06-13 after current code/documentation changes. | + +## Tracker Update Recommendations + +Update `docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md` only after this review is accepted, using these rows: + +| Tracker Row | Suggested Status | Rationale | +| --- | --- | --- | +| Reused Chatwoot frontend verification | Keep `Review`; attach passed browser evidence | API, browser, enterprise API, and enterprise browser smoke now pass, but fixture/realtime/provider live gates remain before claiming full direct-connect parity. | +| Serializer/envelope parity | Keep `Review`; split into fixture-tested subrows | Route parity is strong, but frontend direct-connect depends on exact payloads. | +| Route parity | Mark generated evidence refreshed; do not mark exact | `0 missing`, but 9 parameter-compatible routes need tests/documented constraints. | +| Realtime parity | Keep `Review`; attach payload fixture evidence | Message/conversation mutation, typing/presence, notification, account-cache, browser subscription, and user notification preference fixtures are covered; live browser smoke also verifies the current notification list and profile notification-preference store requests; remaining risk is provider live-smoke coverage without configured external credentials. | +| Search/indexing | Keep `Review`; retain live Meilisearch gate | Live enterprise-browser smoke reran the full Meilisearch reindex/search path and captured seeded conversation/message/contact/company/article payloads; company/article hooks assert updated objects reach indexing. | +| Widget/public/help-center | Keep `Review`; retain browser smoke evidence | Public/widget flows are highly frontend-visible and include multipart/session/locale edge cases; pre-chat config shape, `cw_conversation` query and cookie session reuse across config boot, message history, labels, and events, locale redirect/fallback, portal/category/article JSON, dashboard article CRUD payloads, category filters, sitemap URLs, suffix-route markdown/tracking-pixel dispatch, raw markdown content, 1x1 pixel cache/body, view side effects, and widget browser-smoke harness navigation are covered; the latest enterprise browser report passed widget mount, messages, inbox-members, and campaigns checks. | +| Enterprise features | Keep `Review`; split provider-enabled vs feature-gated behavior | Frontend should see either working behavior or stable disabled states, never fake success. | + +## Immediate Next Work Items + +1. Keep the expanded live enterprise browser smoke as a release gate now that reports/live API v2 shape, value, CSV, auth, store-refresh, CSAT dashboard/public/review-note, custom-role permission, enterprise account literal-path and billing error boundaries, SLA inbox association side effects, Captain document/response frontend filters and response-builder jobs, WhatsApp signed delivery/read/failure status callback, Twilio delivered/read/failed delivery-status callback, TikTok read-receipt callback retry fixture, Facebook page setup/reauthorize callback fixtures, root Instagram/TikTok/Twitter OAuth callback config fixtures, TikTok/LINE/Twilio setup payload/config fixtures, LINE valid/missing/invalid signature webhook fixtures, Telegram setup reauthorization fallback, Facebook/Instagram delivery/read receipt webhook fixtures, background Copilot follow-up job, widget `cw_conversation` query/cookie config/message/labels/events drift, ActiveStorage direct-upload create/complete, and help-center locale/JSON/sitemap/markdown/pixel slices are fixture-covered. +2. Keep the added notification list/profile preference browser-store smoke as a drift guard; core conversation/message, typing/presence, notification/cache, browser subscription, and user preference contracts are now fixture-covered. +3. Keep account actions/contact merge, agent create, agent-bot account, assignable-agent, assignment-policy, agent-capacity, custom-attribute definition, audit-log, automation-rule, campaign, canned-response, bulk-action, Captain/Copilot, and core channel-provider fixtures as drift guards for Woochat CRM/settings/enterprise/channel route changes. +4. Keep provider fixtures as drift guards for future payload changes: inbox assignment override, WhatsApp health/status/failure, Twilio delivered/read/failed delivery-status, TikTok read-receipt, Facebook/Instagram delivery/read receipt, Facebook page setup, root OAuth callback config/upsert/error fallback, email OAuth token payload fallback, Linear/Shopify/Notion integration callback error fallback, Dyte meeting/add-participant, Linear runtime operations, Slack create/delete/list-channel routes, generic integration hooks, Telegram setup reauthorization fallback, TikTok setup, LINE setup, and Twilio setup fixtures are now covered. +5. Keep live Meilisearch search smoke as a release gate and retain `search_reindex.log`, `search_conversations.json`, `search_messages.json`, `search_contacts.json`, `search_companies.json`, and `search_articles.json` evidence under `.tmp/frontend-smoke-live/` when rerun. +6. Attach fixture/smoke evidence to `docs/parity/frontend_contract_inventory.md` and `docs/parity/contract_fixture_coverage.md` rows as slices are proven. + - Current inventory backlog is 0 rows after closing changelog external-Hub triage, voice, WhatsApp authorization, companies, contact notes, contacts, conversations top-level labels/unread-counts, CSAT reports, custom roles, custom filters, dashboard apps, auth/profile literals from `dashboard/api/endPoints.js`, help-center dashboard APIs, inbox conversation/message APIs, and inbox health/member/inbox APIs, Dyte/Linear/Shopify integrations, labels, live reports, macros, MFA, notification subscriptions, notifications, Notion authorization, onboarding, search, SLA policies, applied-SLA reports, SAML settings, summary reports, generic integrations, account literal paths, agent-capacity literal paths, agent bulk-create literal triage, article literal triage, automation literal triage, company literal runtime paths, contacts literal runtime paths, conversations literal label/unread-count paths, `endPoints.spec.js` legacy constants, and CSAT reports literal runtime paths, and inbox conversation literal runtime paths, and inbox message literal runtime path, and inbox literal runtime paths, and Dyte/Linear/Slack/generic integrations literal runtime paths, and labels/macros/notifications/reports/search/SLA literal runtime paths, teams/summary-reports/user-notification-settings/webhooks/year-in-review/CSAT public runtime paths, and widget literal runtime paths. + - Evidence closure moved from inventory backlog to release gates on 2026-06-13: route dump/parity regeneration, `git diff --check`, full `go test ./...`, and live enterprise Woochat/browser smoke all passed. +7. Add new provider-disabled/provider-enabled fixture tests only when Woochat introduces new channel setup, callback, or delivery-status payloads. +8. Re-run full `go test ./...`, `go run ./cmd/dump_routes`, `go run ./cmd/route_parity`, and `git diff --check` after code changes, not just documentation updates. + +## Non-Goals And Assumptions + +- This review does not require changing Woochat/current Chatwoot frontend code. +- This review now covers the Chatwoot/Woochat SAML settings API contract, but does not mark end-to-end SSO login, LDAP, or OIDC fully complete; those remain deployment/feature-gated unless scope changes. +- This review does not claim live provider behavior is complete without configured provider smoke evidence. +- This review treats `reference/chatwoot` as the frontend contract because no separate local Woochat checkout was found. diff --git a/docs/parity/contract_fixture_coverage.md b/docs/parity/contract_fixture_coverage.md new file mode 100644 index 00000000..927106f7 --- /dev/null +++ b/docs/parity/contract_fixture_coverage.md @@ -0,0 +1,69 @@ +# Contract Fixture Coverage + +> Updated: 2026-06-13 +> Purpose: track P0/P1 Chatwoot/Woochat frontend contract evidence while aligning GoChat business-service behavior. + +## Summary + +This file records current focused tests that act as contract fixtures for frontend-direct-connect behavior. A row is `Covered` only when the test asserts response shape, route behavior, or side effects used by the reused Chatwoot/Woochat frontend. A row remains `Needs evidence` when only broad route existence or implementation presence is known. Current inventory backlog is 0 rows after the latest dashboard, CSAT, and widget literal closure. + +## P0 / P1 Coverage Matrix + +| Owner Area | Frontend Contract Source | GoChat Evidence | Current Status | Next Evidence Needed | +| --- | --- | --- | --- | --- | +| Dashboard literal APIs | `dashboard/api/specs/labels.spec.js`, `macros.spec.js`, `notifications.spec.js`, `reports.spec.js`, `search.spec.js`, `slaReports.spec.js` | label/macro/notification/analytics/search/SLA focused handler fixtures listed in `docs/parity/frontend_contract_inventory.md` | Fixture covered for labels, macros, notifications, APIV2 reports, search, and applied-SLA literal frontend paths | Keep as drift guard; add dedicated fixtures only if Woochat introduces new dashboard API literals. | +| Widget and survey literals | `widget/api/*`, `survey/api/*`, `dashboard/api/teams.js`, `summaryReports.js`, `userNotificationSettings.js`, `webhooks.js`, `yearInReview.js` | widget handler, public CSAT, team, summary-report, notification-setting, webhook, and year-in-review focused fixtures listed in `docs/parity/frontend_contract_inventory.md` | Fixture covered for all remaining frontend inventory rows; inventory has 0 `Mapped; needs fixture/smoke evidence` rows | Keep full regression, route parity, and live browser smoke as release gates before declaring product-level completion. | +| Auth/Profile/Account | `dashboard/api/endPoints.js`, `dashboard/api/account.js`, `dashboard/api/mfa.js`, `CacheEnabledApiClient.js` | API/enterprise smoke sign-in, validate-token, profile; browser login + dashboard token validation; `internal/handler/api/v1/auth_handler_test.go::TestChatwootAuthSignInReturnsDeviseHeadersAndUserPayload`; `internal/handler/api/v1/auth_handler_test.go::TestChatwootAuthValidateTokenReturnsPayloadData`; `internal/handler/api/v1/profile_handler_test.go::TestGet_Success`; `internal/handler/api/v1/profile_handler_test.go::TestSetAvailability_ReturnsChatwootUserSerializer`; `internal/handler/api/v1/profile_handler_test.go::TestSetAutoOffline_ReturnsChatwootUserSerializer`; `internal/handler/api/v1/profile_handler_test.go::TestSetActiveAccount_UpdatesMembershipActiveAt`; `internal/handler/api/v1/mfa_handler_test.go::TestProfileMFA_StatusUsesChatwootRawPayload`; `internal/handler/api/v1/mfa_handler_test.go::TestProfileMFA_EnableVerifyBackupAndDisableUseFrontendPayloads`; `internal/handler/api/v1/account_handler_test.go::TestCacheKeys_Success`; `internal/repository/account_repo_test.go::TestAccountUserRepo_UpdateActiveAt_SetsCurrentTimestamp`; auth/CORS token-header tests | Fixture covered for auth/profile bootstrap, availability, auto-offline, active account, MFA payloads, and account cache keys | Keep as drift guard; broaden only if Woochat adds new auth/account API calls. | +| SSO/Identity | `dashboard/api/samlSettings.js` | `internal/handler/api/v1/account_saml_settings_handler_test.go::TestAccountSamlSettingsHandlerTestSuite/TestChatwootFrontendCollectionCRUDPayloads`; `RegisterAccountSamlSettingsRoutes` | Fixture covered for Woochat SAML settings collection GET/POST/PUT/DELETE, `{ saml_settings: ... }` create/update wrapper, raw `response.data` payload, `sso_url`/`certificate` aliases, fingerprint/id fields, delete/not-found behavior, and legacy id-route compatibility | Keep as drift guard; end-to-end SSO login, LDAP, and OIDC remain deployment/feature-gated outside this settings API contract. | +| Inbox/Agents/Assignment | `dashboard/api/inboxes.js`, `agents.js`, `teams.js`, `assignmentPolicies.js`, `agentCapacityPolicies.js`, `assignableAgents.js` | API/enterprise smoke inbox list and agent-capacity list/users; `internal/handler/api/v1/inbox_handler_parity_test.go::TestInboxHandler_ChatwootSerializerParity`; `internal/handler/api/v1/inbox_handler_parity_test.go::TestInboxHandler_HealthReturnsWhatsAppCloudRawPayload`; `internal/handler/api/v1/inbox_handler_parity_test.go::TestInboxHandler_HealthReturnsProviderFailureState`; `internal/handler/api/v1/inbox_member_handler_test.go::TestAccountScopedInboxMembers_ChatwootPayloadAndDiffUpdate`; `internal/handler/api/v1/assignment_policy_handler_test.go::TestInboxAssignmentPolicy_ChatwootRoutes`; `internal/handler/api/v1/agent_capacity_handler_test.go::TestChatwootPolicyInboxLimitAndUserFlow`; `internal/handler/api/v1/agent_capacity_handler_test.go::TestChatwootPolicyInboxLimitAndUserValidationErrors`; `internal/handler/api/v1/facebook_callbacks_handler_test.go::TestFacebookCallbacks_RegisterFacebookPage`; `internal/handler/api/v1/tiktok_channel_handler_test.go::TestTikTokChannel_Create_ChatwootSetupPayloadAndConfig`; LINE/Twilio setup config assertions in `internal/handler/api/v1/line_channel_handler_test.go` and `internal/handler/api/v1/twilio_channel_handler_test.go`; root OAuth callback inbox config assertions in `internal/router/router_test.go::TestChannelCallbacksCreateInboxesAndRedirect`; assignable-agent route tests | Fixture covered for inbox list/detail/channel settings, members, health, provider failure state, Facebook callback-created inbox/channel config, root OAuth-created Instagram/TikTok/Twitter inbox config, TikTok/LINE/Twilio setup-created inbox/channel config, assignment-policy inbox override, agent-capacity user create/list/delete, inbox-limit create/update/delete, inbox avatar/campaigns/sync-template literal runtime paths, account create literal path, account-scoped agent bulk-create runtime path, and validation/not-found envelopes on Chatwoot literal nested paths | Keep as drift guard; add new provider callback fixtures only when Woochat adds channel setup UI calls or provider payloads change. | +| Conversation/Message | `dashboard/api/conversations.js`, `inbox/message.js`, `inbox/conversation.js`, store modules | API/enterprise smoke conversation list, message list, and message create; `internal/handler/api/v1/conversation_handler_crud_test.go::TestList_Success`; `internal/handler/api/v1/message_handler_test.go::TestCreate_ChatwootFrontendPayloadDefaultsOutgoing`; `internal/handler/api/v1/message_handler_test.go::TestCreate_MultipartAttachmentPersistsAndSerializes`; `internal/handler/api/v1/message_handler_test.go::TestDelete_Success`; `internal/handler/api/v1/message_handler_test.go::TestRetry_Success`; `internal/handler/api/v1/conversation_handler_test.go::TestUnread_DisplayIDRouteSetsLastSeenBeforeIncoming`; `internal/handler/api/v1/conversation_handler_test.go::TestUpdateLastSeen_DisplayIDRouteMarksNotificationRead`; `internal/service/message_service_test.go::TestMessageService_Update`; `internal/service/message_service_test.go::TestMessageService_Delete`; `internal/service/message_service_test.go::TestMessageService_Retry`; `internal/service/conversation_service_test.go::TestConversationService_MutationEventsCarryChatwootChangeData`; `internal/service/conversation_service_test.go::TestConversationService_ToggleTypingDispatchesChatwootPayload`; `internal/handler/api/v1/draft_message_handler_test.go::Test_CollectionDrafts_UseDisplayIDRoute`; `internal/handler/api/v1/conversation_participant_handler_test.go::Test_DisplayIDRouteEndToEnd`; `internal/handler/api/v1/conversation_serializer_test.go` serializer fixtures; `internal/ws/event_publisher_test.go::TestEventPublisher_MessageCreatedChatwootPayloadShape`; `internal/ws/event_publisher_test.go::TestEventPublisher_MessageUpdatedChatwootPayloadShape`; `internal/ws/event_publisher_test.go::TestEventPublisher_ConversationMutationChatwootPayloadShape`; `internal/ws/event_publisher_test.go::TestEventPublisher_TypingChatwootPayloadShape`; `internal/ws/event_publisher_test.go::TestEventPublisher_PresenceUpdateChatwootPayloadShape`; `internal/ws/presence_test.go::TestPresenceTracker_BroadcastsChatwootPresenceUpdatePayload`; `internal/ws/presence_test.go::TestCleanupExpired_BroadcastsChatwootOfflinePayloadAndClearsStatus` | Fixture covered for list envelope, meta sender, labels, attributes, last messages, message create, sender, content attributes, attachments, display conversation IDs, message update external-error dispatch, delete tombstones/attachment cleanup, retry status reset/external-error cleanup, unread transition last-seen rollback, update-last-seen notification read side effect, draft show/create/list display-ID routes, participants fetch/update/remove display-ID routes, conversation status/priority/labels/assignment event change data, typing on/off event data, Chatwoot `presence.update` users/contacts maps, heartbeat refresh, and expiry offline broadcasts, and `message.created`/`message.updated`/`conversation.updated`/`conversation.typing_on` realtime wire shapes | Keep as drift guard; broaden only if Woochat adds new conversation/message realtime calls. | +| CRM | `dashboard/api/contacts.js`, `companies.js`, `contactNotes.js`, labels modules | API/enterprise smoke contact and company show; `internal/handler/api/v1/crm_frontend_smoke_test.go::TestChatwootFrontendCRMSmoke`; `internal/handler/api/v1/contact_handler_crud_test.go::TestChatwootFrontendContactMergeImportExportContracts`; `internal/handler/api/v1/contact_handler_crud_test.go::TestChatwootFrontendContactsSpecRuntimeRoutes`; `internal/handler/api/v1/contact_handler_crud_test.go::TestListAttachmentsTimelineDepthMatchesChatwootFrontend`; `internal/handler/api/v1/contact_handler_crud_test.go::TestUpdatePublishesChatwootContactUpdatedEvent`; `internal/handler/api/v1/contact_handler_crud_test.go::TestDeletePublishesChatwootContactDeletedEvent`; `internal/handler/api/v1/contact_handler_crud_test.go::TestMergePublishesBaseUpdateAndMergeeDeleteEvents`; `internal/handler/api/v1/company_handler_test.go::TestUpdatePublishesChatwootCompanyUpdatedEvent`; contact/company/custom-attribute tests | Fixture covered for dashboard CRM list/show/search/update/labels/notes/company-contact paths, contacts literal-spec runtime paths, contact merge/import/export contracts, attachment timeline ordering/scoping/sender/media fields, and contact/company realtime update event payloads plus contact delete and merge source-side events | Keep as drift guard; broaden only if Woochat adds new CRM API calls. | +| Widget/Public | `widget/api/endPoints.js`, widget direct-upload components, public inbox APIs | API/enterprise smoke widget config and message create; `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootConfig_Success`; `TestWidgetHandler_ChatwootConfigPreChatFormOptionsMatchFrontendMixin`; `TestWidgetHandler_ChatwootConversationQueryTokenReusesSession`; `TestWidgetHandler_ChatwootConfigCwConversationReusesContactInbox`; `TestWidgetHandler_ChatwootConversationCookieReusesSession`; `TestWidgetHandler_ChatwootEventsAndLabels`; `TestWidgetHandler_ChatwootMessages_AuthTokenAndNestedPayload`; `TestWidgetHandler_ChatwootMessagePublishesWidgetRealtimePayload`; `TestWidgetHandler_ChatwootMessageDirectUploadAttachment`; `internal/handler/api/v1/upload_handler_test.go::TestUploadHandler_WidgetActiveStorageDirectUploadFlow`; `TestWidgetHandler_PublicAPIMessageDirectUploadAttachment`; `TestWidgetHandler_ChatwootCampaigns_Success`; `TestWidgetHandler_PublicAPIInboxContactConversationMessageFlow`; public API contact/conversation/message tests | Fixture covered for widget config/contact token bootstrap, Chatwoot `preChatFormEnabled`/`preChatFormOptions` frontend mixin shape, `cw_conversation` popout query and cookie session reuse, config boot contact-inbox reuse without creating a new visitor, message create/list envelopes, widget `message.created` pubsub-token realtime delivery, labels/events with header/query/cookie auth, ActiveStorage widget direct-upload create/complete fields, direct-upload attachment payloads, campaigns, public contact update shape, public conversation status/read flows, core public API message windows, and browser-smoke harness coverage for reused widget boot plus messages/inbox-members/campaign requests | Keep as drift guard; rerun live browser smoke when widget SDK requests change. | +| Automation/Macros/Campaigns | `dashboard/api/automation.js`, `dashboard/api/macros.js`, `dashboard/api/campaigns.js`, `dashboard/api/cannedResponse.js`, `dashboard/api/bulkActions.js` | API/enterprise smoke automation and macros; `internal/handler/api/v1/automation_rule_handler_test.go::TestChatwootFrontendCRUDCloneTogglePayloadsAndValidation`; `internal/handler/api/v1/campaign_handler_test.go::TestChatwootFrontendPayloadsAndLifecycleUseDisplayID`; `internal/handler/api/v1/campaign_handler_test.go::TestStartCreatesCampaignConversationsAndMessages`; `internal/handler/api/v1/macro_handler_test.go::TestExecute_UsesConversationDisplayIDsAndMutatesConversation`; `internal/handler/api/v1/macro_handler_test.go::TestExecute_ChatwootFrontendAwaitsEmptyOKAndSupportsSingleConversationID`; canned-response CRUD/search fixtures in `internal/handler/api/v1/canned_response_handler_test.go`; automation-rule, bot-rule, campaign, macro, and bulk-action suites; `internal/handler/api/v1/bulk_action_handler_test.go::TestBulkActionHandler_ConversationEnqueuesChatwootPayload`; `internal/service/conversation_maintenance_worker_test.go::TestConversationMaintenanceJobsConversationBulkActionQueuesSearchIndex`; `internal/service/conversation_maintenance_worker_test.go::TestConversationMaintenanceJobsContactBulkActionQueuesSearchIndexAndCompletes`; `internal/service/conversation_maintenance_worker_test.go::TestConversationMaintenanceJobsTriggerScheduledItemsFanOut`; `internal/service/conversation_maintenance_worker_test.go::TestConversationMaintenanceJobsProcessCampaignSnoozeAndResolution`; `internal/service/conversation_maintenance_worker_test.go::TestConversationMaintenanceJobsCampaignTriggerDispatchesEvents` | Fixture covered for automation-rule create/list/update/clone/toggle/delete payloads, validation error envelope, Chatwoot condition/action param normalization, campaign nested `{ campaign: ... }` create/update payloads, Chatwoot display-id route semantics, campaign start/stop response envelopes, campaign trigger conversation/message side effects, scheduled campaign trigger job dispatch payload/idempotency/completion, campaign trigger conversation/message/opened/outgoing event dispatch, dashboard audience/trigger/template JSON shapes, frontend audience-array trigger tolerance, macro execute side effects against display/single conversation IDs, empty-200 execute response awaited by the frontend, validation envelope for missing body, canned-response raw CRUD/search payloads, and bulk-action enqueue/progress completion plus conversation/contact search-index side effects | Keep as drift guard; re-open only if Woochat changes macro execution to consume a non-empty response body. | +| Help Center | `dashboard/api/helpCenter/*`, `widget/api/endPoints.js` help-center article paths | `internal/handler/api/v1/portal_handler_test.go::TestPublicRedirectDefaultLocale`; `TestPublicRedirectDefaultLocaleFallsBackToPortalLocale`; `TestPublicSitemap_ReturnsPublishedArticleURLs`; `internal/handler/api/v1/category_handler_test.go::TestPublicList_ReturnsChatwootCategoryPayloads`; `TestPublicGet_FiltersBySlugAndLocale`; `internal/handler/api/v1/article_handler_test.go::TestDashboardArticleCRUD_ChatwootFrontendPayloadsAndRoutes`; `internal/handler/api/v1/article_handler_test.go::TestPublicList_WidgetPopularArticles`; `TestPublicList_CategoryRouteFiltersArticlesBySlugAndLocale`; `TestPublicMarkdown_ReturnsOnlyPublishedMarkdown`; `TestPublicTrackingPixel_IncrementsPublishedArticleViews` | Fixture covered for dashboard article create/list/show/edit/update/delete payload shape and slug-portal routes, locale redirect/default fallback, sitemap URL/content type excluding drafts, category JSON/list by slug+locale, article JSON list sorting/meta/category filters, Chatwoot suffix-compatible `.md` raw markdown, and `.png` 1x1 tracking pixel side effects | Keep as drift guard; Article spec bare `/api/v1/portals/...` URLs are frontend unit-test artifacts; runtime account-scoped article paths are fixture-covered. add browser smoke only if Woochat portal UI changes. | +| CSAT/Survey | `dashboard/api/csatReports.js`, `survey/api/*`, public CSAT routes | API/enterprise smoke public CSAT show, CSAT list/metrics/download; `internal/handler/api/v1/csat_survey_handler_test.go::TestCSATFrontendContractShapes`; `internal/handler/api/v1/csat_survey_handler_test.go::TestMetrics_DashboardValueDriftFiltersMatchChatwootFrontend`; `internal/handler/api/v1/csat_survey_handler_test.go::TestUpdate_ChatwootReviewNotesAuditAndSerializerParity`; `internal/handler/api/v1/csat_survey_handler_test.go::TestPublicCsatUpdate_ChatwootPutPersistsShowPayload`; `internal/handler/api/v1/csat_survey_handler_test.go::TestPublicCsatUpdate_LockedAfter14Days`; CSAT survey handler/service tests | Fixture covered for dashboard list/metrics/download, dashboard metric value-drift filters, Chatwoot account/date-only `total_sent_messages_count`, review-note audit/serializer behavior, public PUT show/update persistence, and locked-after-14-days non-mutation behavior | Keep status-transition/job-send fixtures as follow-up drift guards. | +| Search/Indexing | `dashboard/api/search.js`, conversation search store, `dashboard/api/companies.js`, help-center article search calls | Live enterprise-browser smoke on 2026-06-13 reran Meilisearch reindex/search and captured seeded `/search/conversations`, `/search/messages`, `/search/contacts`, `/companies/search`, and `/search/articles` Chatwoot payloads as `.tmp/frontend-smoke-live/search_conversations.json`, `search_messages.json`, `search_contacts.json`, `search_companies.json`, `search_articles.json`, plus `search_reindex.log`; smoke seed creates a help-center portal/category/article; search handler/indexer tests; company/article hooks assert updated objects reach indexing; Meilisearch request/filter contract tests; release-mode fallback rejection config test | Fixture and live Meilisearch gate covered for the current reused frontend search paths | Keep as release drift guard; rerun live Meilisearch smoke/reindex after search schema or searchable mutation changes. | +| Reports/Audit | `dashboard/api/reports.js`, `summaryReports.js`, `liveReports.js`, `auditLogs.js` | Enterprise smoke audit logs; `internal/handler/api/v1/analytics_handler_test.go::TestAPIV2Reports_ChatwootPayloadShapes`; `internal/handler/api/v1/analytics_handler_test.go::TestAPIV2Reports_AllFrontendEndpointShapes`; `internal/handler/api/v1/analytics_handler_test.go::TestAPIV2Reports_TimeseriesTimezoneValueParity`; `internal/handler/api/v1/analytics_handler_test.go::TestAPIV2Reports_CSVDownloadEntrypointsMatchChatwootFrontend`; `internal/handler/api/v1/analytics_handler_test.go::TestAPIV2Reports_OutgoingMessagesCountValueParity`; `internal/handler/api/v1/live_report_handler_test.go::TestAPIV2LiveReports_ChatwootFrontendPayloadShapes`; `internal/handler/api/v1/live_report_handler_test.go::TestAPIV2LiveReports_StoreRefreshSequenceMatchesChatwootFrontend`; `internal/router/router_test.go::TestAPIV2LiveReportsRouterAuthAndAccountScope`; `internal/middleware/account_scope_test.go::TestAccountScope_RouteAccountMustMatchContext`; `internal/handler/api/v1/audit_handler_test.go::TestList_ChatwootPayloadFiltersAndSerializer`; analytics/report/audit/live-report service and handler tests | Fixture covered for audit list payloads, fixed Chatwoot page size, pagination meta, descending page order, API v2 reports.js index/summary/conversations/agents/inboxes/labels/teams/conversations_summary/conversation_traffic/bot_metrics/bot_summary endpoint shapes, frontend CSV download entrypoints, timezone-offset day grouping value parity, outgoing message count value parity, live-report frontend payloads, live-report store-refresh sequence, and live-report auth/account-scope enforcement | Keep as drift guard; broaden only if Woochat adds new audit filters or report pagination calls. | +| Notifications/Realtime/Cache | `dashboard/store/modules/notifications`, `notificationSubscription.js`, user notification settings, `helper/actionCable.js` | `internal/handler/api/v1/notification_handler_test.go` list/read/unread/delete/destroy-all fixtures; `internal/handler/api/v1/notification_handler_test.go::TestNotificationHandler_MutationsPublishChatwootRealtimePayload`; `internal/handler/api/v1/notification_subscription_handler_test.go::TestNotificationSubscriptionCreateAcceptsPushHelperPayload`; `internal/handler/api/v1/notification_subscription_handler_test.go::TestNotificationSubscriptionCreateAcceptsAccountScopedPushHelperPayload`; `internal/handler/api/v1/notification_setting_handler_test.go::TestNotificationSettingHandlerSuite/TestUpdate_ReturnsRawChatwootPayload`; `internal/ws/event_publisher_test.go::TestEventPublisher_NotificationChatwootPayloadShape`; `internal/ws/event_publisher_test.go::TestEventPublisher_AccountCacheInvalidatedChatwootPayloadShape`; `internal/ws/event_publisher_test.go::TestEventPublisher_WidgetEvent_PubsubTokenRoomDelivery`; `internal/handler/ws/subscriber.go` topic bridge for notification updated/deleted and account cache invalidation | Fixture covered for notification list/read-state/delete REST behavior, source-side read/unread/snooze/delete realtime publication, PushHelper browser subscription create/destroy including account-scoped routes, user notification settings show/update selected flag persistence, `notification.created`/`notification.updated`/`notification.deleted` realtime payloads, `account.cache_invalidated.cache_keys` label/inbox/team revalidation, and widget `message.created` delivery to the contact `pubsub_token` room | Keep as drift guard; add browser-store smoke evidence if Woochat changes subscription/preference UI behavior. | +| Enterprise/Captain | `dashboard/api/captain/*`, `customRole.js`, `sla.js`, enterprise account APIs | Enterprise API and browser route-request smoke passed for SLA, CSAT, automation, macros, audit, custom roles, capacity, Captain, and Copilot; `internal/handler/api/v1/enterprise_account_handler_test.go::TestEnterpriseAccountLiteralFrontendPathsUseCurrentAccountContext`; `internal/handler/api/v1/enterprise_account_handler_test.go::TestEnterpriseAccountBillingErrorBoundariesDoNotMutateAccount`; `internal/handler/api/v1/enterprise_account_handler_test.go::TestEnterpriseAccountLimits_CaptainUsageDoesNotExposeNegativeAvailability`; `internal/handler/api/v1/enterprise_account_handler_test.go::TestEnterpriseAccountSubscriptionPreservesExistingCustomerState`; `internal/handler/api/v1/custom_role_handler_test.go::TestChatwootPermissionSetCreateUpdateListShowParity`; `internal/handler/api/v1/captain_assistant_handler_test.go::TestCaptainAssistantHandler_PlaygroundLegacyNoLLMFallback`; `internal/handler/api/v1/captain_assistant_handler_test.go::TestCaptainAssistantHandler_PlaygroundV2AppendsCurrentMessageOnce`; `internal/handler/api/v1/captain_assistant_handler_test.go::TestCaptainAssistantHandler_PlaygroundV2ProviderErrorReturnsChatwootFallback`; `internal/handler/api/v1/captain_resource_parity_handler_test.go::TestCaptainDocumentHandler_ChatwootDocumentPayloadsAndSync`; `internal/handler/api/v1/captain_resource_parity_handler_test.go::TestCaptainAssistantResponseHandler_ChatwootResponsePayloadsAndFilters`; `internal/service/captain_document_service_test.go::TestCaptainDocumentService_RequestSyncQueuesDurableJob`; `internal/service/captain_document_service_test.go::TestCaptainDocumentService_RequestCrawlQueuesParserJobs`; `internal/service/captain_document_service_test.go::TestCaptainDocumentService_ResponseBuilderCreatesResponsesAndEmbeddingJobs`; `internal/handler/api/v1/copilot_thread_handler_test.go::TestCopilotThreadCreateProviderDisabledReturnsStableUnavailableAssistantMessage`; `internal/handler/api/v1/copilot_thread_handler_test.go::TestCopilotThreadCreateProviderEnabledPersistsGeneratedAssistantMessage`; `internal/service/copilot_response_worker_test.go::TestCopilotResponseJobForFollowupMessageUsesStoredThreadHistoryAndIdempotency`; `internal/handler/api/v1/sla_policy_handler_test.go::TestSlaPolicyHandler_InboxAssociationChatwootPayloadAndSideEffects`; captain/custom-role/SLA/agent-capacity tests | Fixture covered for enterprise browser route smoke, Chatwoot enterprise account literal-path limits/billing actions, Captain limit availability clamping, subscription existing-customer no-op side effect, and billing error no-mutation boundaries, Chatwoot custom-role permission key create/update/list/show parity, Captain document list/show/sync payload keys plus `assistant_id`/`filter`/`source`/`search_key` filters, Captain assistant-response payload keys plus `assistant_id`/`document_id`/`status`/`search` filters and `Captain::Document` documentable shape, Captain document sync/crawl response-builder job queue/idempotency payloads, legacy `CaptainDocument` cleanup, generated approved FAQ responses, and embedding job fan-out, Captain playground legacy provider-disabled fallback, V2 provider-enabled response shape/history handling, V2 provider-error Chatwoot `conversation_handoff` fallback, Copilot thread provider disabled/enabled message persistence, background Copilot follow-up job payload/history/idempotency, and SLA inbox association response/DB side effects | Keep as drift guard; broaden only when Woochat adds new Captain/Copilot provider modes. | +| Integrations/Channels | `dashboard/api/channel/*`, `integrations/*` | provider/channel service and webhook tests; Dyte meeting/add-participant handler fixtures; Linear teams/team-entities/create/link/unlink/search/linked-issues handler fixtures using fake GraphQL; Slack create/delete/list-all-channels handler fixtures using fake HTTP; generic app/hook no-trailing-slash fixtures; WhatsApp health raw-payload and provider-failure fixtures in `internal/handler/api/v1/inbox_handler_parity_test.go`; Facebook page setup/provider-failure/reauthorize fixtures in `internal/handler/api/v1/facebook_callbacks_handler_test.go`; root OAuth callback redirect/config fixture, existing-channel upsert fixture, email OAuth provider-error fallback fixture, and Linear/Shopify/Notion provider-error fallback fixture in `internal/router/router_test.go`; Telegram webhook success/failure reauthorization fixture in `internal/channel/provider/telegram_test.go`; TikTok setup payload/config fixture in `internal/handler/api/v1/tiktok_channel_handler_test.go`; LINE/Twilio setup payload/config fixtures in `internal/handler/api/v1/line_channel_handler_test.go` and `internal/handler/api/v1/twilio_channel_handler_test.go`; WhatsApp delivered/read/failed-delivery, Twilio delivered/read/failed-delivery, TikTok read-receipt, LINE valid/missing/invalid signature, and `TestFacebookWebhookDeliveryReceiptPersistsDeliveredStatus`/`TestFacebookWebhookReadReceiptPersistsReadStatusForContactConversation` and `TestInstagramWebhookDeliveryReceiptPersistsDeliveredStatus`/`TestInstagramWebhookReadReceiptPersistsReadStatusForContactConversation` fixtures in `internal/handler/webhook/webhook_lookup_test.go` | Fixture covered for current reused frontend provider/channel paths; WhatsApp signed status callback now persists delivered/read/failed message statuses, delivery status timestamps, status events, frontend-visible `external_error` content attributes for failures, and duplicate same-status callbacks as idempotent no-ops; Twilio delivery-status callback now normalizes callback phone numbers, persists delivered/read/failed message statuses, delivery status rows, status events, and frontend-visible `external_error` for failures, and treats duplicate status callbacks as idempotent no-ops without duplicate realtime events; TikTok read receipts now persist read message status, delivery status, status event, and duplicate read-receipt retries as idempotent no-ops; contact-level read receipts now also backfill per-contact delivery status rows for messages already at the target read status (`TestIncomingPersisterQueuesContactMessagesStatusUpdateWithWorker`); LINE webhooks now cover valid, missing-signature, and invalid-signature behavior without creating phantom messages; Facebook and Instagram delivery/read receipts now persist delivered/read message statuses, delivery status rows, status events from their Chatwoot-compatible webhook endpoints, and duplicate receipt retries as idempotent no-ops; Facebook `facebook_pages.json` returns Chatwoot `data.page_details`/`user_access_token` and provider failures as 422, `register_facebook_page` returns the raw inbox payload, and `reauthorize_page` updates persisted page config; root Instagram/TikTok/Twitter OAuth callbacks now persist serializer-readable inbox channel config before redirecting Woochat to inbox setup, and repeated Google/Instagram/TikTok/Twitter callbacks refresh existing channel/inbox config and redirect to settings, while Google/Microsoft token payload errors redirect to the frontend fallback without creating phantom inboxes; Linear/Shopify/Notion OAuth token payload errors redirect to stable Chatwoot frontend destinations and do not create phantom integration hooks; Telegram webhook setup success stores `webhook_url`, and setup failure marks `reauthorization_required` instead of silently exposing a working channel; TikTok setup now returns a stable raw `{channel,inbox}` payload and persists `tiktok_business_id`/`access_token` channel config even when provider setup is disabled; LINE and Twilio setup now assert raw inbox payloads plus persisted channel linkage/config | Keep as drift guard; add signed fixtures when Woochat or provider payloads introduce new delivery states. Generic `integrations.js` apps/hooks, Slack, and Shopify frontend paths are covered by `TestIntegrationHookHandlerSuite/TestChatwootFrontendAppsAndHooksNoTrailingSlashPayloads`, Slack handler/service fixtures, and `TestShopifyIntegration_Auth_ReturnsChatwootRedirectPayload`. | + +## Current Focused Commands + +```bash +GOCACHE=/tmp/go-build-cache GOMODCACHE=/tmp/go-mod-cache go test ./internal/handler/api/v1 -run 'TestAgentCapacityHandlerSuite|TestArticleHandlerSuite' +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -run 'TestInboxHandler_ChatwootSerializerParity|TestInboxHandler_HealthReturnsWhatsAppCloudRawPayload|TestInboxHandler_HealthReturnsProviderFailureState|TestInboxMemberHandlerSuite|TestInboxHandler_SensitiveFieldsRequireAdministratorRole|TestAgentBotHandlerSuite/TestAccountChatwootPayloadsAndRoutes|TestAgentCapacityHandlerSuite/TestChatwootPolicyInboxLimitAndUserFlow|TestAgentCapacityHandlerSuite/TestChatwootPolicyInboxLimitAndUserValidationErrors|TestAssignableAgentHandlerSuite/(TestList_StandaloneResourceUsesFrontendInboxIDsQuery|TestList_ResponseStructure)|TestAssignmentPolicyHandlerTestSuite/(TestPluralAssignmentPolicies_ChatwootPayloads|TestInboxAssignmentPolicy_ChatwootRoutes)|TestAgentHandlerSuite/TestCreateAgentChatwootFrontendPayload|TestCustomAttributeDefinitionHandler_ListChatwootFrontendPayload' +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -run 'Test(FacebookCallbacks_RegisterFacebookPage|GoogleAuthorization_ReturnsChatwootPayload|MicrosoftAuthorization_ReturnsChatwootPayload|InstagramAuthorization_ReturnsChatwootPayload|TikTokChannel_Create_ChatwootSetupPayloadAndConfig|TwilioChannel_Create_Success|TwitterAuthorization_ReturnsChatwootPayload|InboxHandler_ChatwootSerializerParity|WhatsAppCallHandler_AccountRoutesMatchFrontendAPI|WhatsAppCallHandler_ActionsAndRecordingPayloads)$' -count=1 && GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/router -run 'TestChannelCallbacks(CreateInboxesAndRedirect|UpdateExistingInboxesAndRedirectToSettings|RedirectErrorsToNewInbox)$' -count=1 && GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/webhook -run 'Test(TwilioDeliveryStatusPersistsDeliveredAndReadStatuses|WhatsAppWebhookPersistsDeliveredAndReadStatuses|TikTokWebhookReadReceiptUpdatesMessageStatus|FacebookWebhookDeliveryReceiptPersistsDeliveredStatus|InstagramWebhookDeliveryReceiptPersistsDeliveredStatus)$' -count=1 +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/router -run 'TestTwilioVoiceRoutesServeConferenceAndPersistCallbacks$' -count=1 && GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/service -run 'TestWhatsAppAuthorization_(CreateEmbeddedSignupInbox|ReauthorizesExistingInbox)$' -count=1 +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/ws -run 'TestEventPublisher_MessageCreatedChatwootPayloadShape|TestEventPublisher_WSMessageFormat|TestEventPublisher_ConversationEvent_RoomDelivery|TestEventPublisher_WidgetEvent_PubsubTokenRoomDelivery' +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -run 'TestConversationCrudTestSuite/TestList_Success|TestMessageHandlerTestSuite/TestCreate_ChatwootFrontendPayloadDefaultsOutgoing|TestMessageHandlerTestSuite/TestList_Success|TestMessageHandlerTestSuite/TestCreate_MultipartAttachmentPersistsAndSerializes|TestConversationSerializer' +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -run 'TestConversationCrudTestSuite/(TestList_Success|TestUpdateLabels_Success|TestToggleStatus.*|TestToggleTyping.*|TestFilter_ChatwootPayload.*)|TestConversationHandlerTestSuite/(TestMeta_Success|TestUnreadCounts_Success|TestTranscript_Success)|TestMessageHandlerTestSuite/(TestList_Success|TestCreate_ChatwootFrontendPayloadDefaultsOutgoing|TestCreate_MultipartAttachmentPersistsAndSerializes|TestDelete_Success|TestRetry_Success|TestTranslate_Success)' -count=1 +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -run 'TestChatwootFrontendCRMSmoke|TestContactHandlerCRUDTestSuite/(TestChatwootFrontendContactMergeImportExportContracts|TestMergePublishesBaseUpdateAndMergeeDeleteEvents|TestInitiateCall_CreatesConversationCallAndVoiceMessage)|TestCsatSurveyHandlerTestSuite/TestCSATFrontendContractShapes|TestCustomRoleHandlerSuite|TestCustomFilterHandlerSuite|TestDashboardAppHandlerSuite' +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/widget -run 'TestWidgetHandler_ChatwootConfig_Success|TestWidgetHandler_ChatwootMessages_AuthTokenAndNestedPayload|TestWidgetHandler_ChatwootMessagePublishesWidgetRealtimePayload|TestWidgetHandler_ChatwootMessageDirectUploadAttachment|TestWidgetHandler_ChatwootCampaigns_Success' +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -run 'TestArticleHandlerSuite/(TestDashboardArticleCRUD_ChatwootFrontendPayloadsAndRoutes|TestReorder_PositionsHashScoped|TestBulkActions_FrontendRoutes|TestBulkTranslate_FrontendRouteQueuesAndReturnsConflict)|TestCategoryHandlerSuite/(TestCreate_RawFrontendPayloadAndSlugPortal|TestList_Success|TestReorder_Success)|TestPortalHandlerSuite/(TestList_ReturnsChatwootPayloadEnvelope|TestUpdate_Success|TestDelete_BySlugReturnsOK|TestSendInstructions_ChatwootPayload)' -count=1 +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -run 'TestInboxHandler_(ChatwootSerializerParity|HealthReturnsWhatsAppCloudRawPayload|HealthReturnsProviderFailureState|ChatwootCreateUpdateRequestBinding)|TestInboxMemberHandlerSuite/TestAccountScopedInboxMembers_ChatwootPayloadAndDiffUpdate|TestInboxSyncTemplates_ChatwootQueuedResponse' -count=1 +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -run 'TestAuditHandlerSuite/(TestList_ChatwootPayloadFiltersAndSerializer|TestList_ChatwootPaginationMetaAndDescendingOrder)|TestAutomationRuleHandlerTestSuite/TestChatwootFrontendCRUDCloneTogglePayloadsAndValidation|TestCampaignHandlerTestSuite/TestChatwootFrontendPayloadsAndLifecycleUseDisplayID|TestCampaignHandlerSuite/TestStartCreatesCampaignConversationsAndMessages|TestMacroHandlerSuite/TestExecute_UsesConversationDisplayIDsAndMutatesConversation|TestMacroHandlerSuite/TestExecute_ChatwootFrontendAwaitsEmptyOKAndSupportsSingleConversationID|TestCannedResponseHandlerSuite/(TestList_Success|TestList_SearchParamReturnsRawRankedArray|TestCreate_RawFrontendBodyReturnsRawPayload|TestUpdate_PatchRawBodyIsAccountScoped|TestDelete_ReturnsOKEmptyAndScopesAccount)|TestBulkActionHandler_(ConversationEnqueuesChatwootPayload|ContactEnqueuesChatwootPayload|InvalidTypeMatchesChatwootPayload)' -count=1 +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -run 'Test(LinearIntegration_ChatwootFrontendRuntimeRoutes|SlackIntegration_ListAllChannels_NoTrailingSlash_ReturnsChannelArray|SlackIntegration_(Create|Delete)_NoTrailingSlash_Returns|DyteIntegrationHandler(CreateMeetingReturnsMessagePayload|AddParticipantReturnsAuthToken))|TestIntegrationHookHandlerSuite/TestChatwootFrontendAppsAndHooksNoTrailingSlashPayloads|TestLabelHandlerSuite/(TestListTags_Success|TestCreateTag_RawNameCompatibility|TestUpdateTag_ChatwootPayloadAndAccountScope|TestDeleteTag_ReturnsOKAndRemovesAssociations)|TestLiveReportHandlerTestSuite/(TestAPIV2LiveReports_ChatwootFrontendPayloadShapes|TestAPIV2LiveReports_StoreRefreshSequenceMatchesChatwootFrontend)|TestMacroHandlerSuite/(TestList_Empty|TestCreate_Success|TestUpdateDeleteAuthorizationAndPayload|TestExecute_ChatwootFrontendAwaitsEmptyOKAndSupportsSingleConversationID)|TestMFAHandlerSuite/TestProfileMFA_(StatusUsesChatwootRawPayload|EnableVerifyBackupAndDisableUseFrontendPayloads)|TestNotificationSubscription(CreateAcceptsPushHelperPayload|CreateAcceptsAccountScopedPushHelperPayload|CreateAcceptsRailsWrapperAndUpdatesExisting|DestroyUsesPushTokenAndReturnsEmptyOK|DestroyAccountScopedUsesPushToken)$|TestNotificationHandler_(List_ChatwootEnvelopeAndIncludes|MutationsPublishChatwootRealtimePayload)|TestNotionIntegration_Authorization_ReturnsChatwootPayload|TestUpdateOnboarding_Success|TestSearchHandler_(GlobalSearch_UsesReferenceResultTypes|SearchConversations_ChatwootPayloadShape|SearchMessages_HydratesChatwootMessagePayload|SearchArticles_HydratesPortalAndCategoryPayload)|TestSlaPolicyHandler_(List_Success|Create_Success|InboxAssociationChatwootPayloadAndSideEffects|ListAppliedSlas_ChatwootPayloadAndFilters|GetAppliedSlaMetrics_ChatwootReportShape|GetAppliedSlaDownload_Success)' -count=1 +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/service -run 'TestLinearIntegrationService_ChatwootFrontendPayloads$' -count=1 && GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -run 'TestShopifyIntegration_Auth_ReturnsChatwootRedirectPayload$' -count=1 +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -run 'TestCaptainAssistantHandler_|TestCaptain(DocumentHandler_ChatwootDocumentPayloadsAndSync|AssistantResponseHandler_ChatwootResponsePayloadsAndFilters|ScenarioHandler_ChatwootScenarioPayloadsAndScope|CustomToolHandler_ChatwootToolPayloadsAndScope|BulkActionHandler_ChatwootResourceActions|CustomToolHandler_ChatwootTestToolPayload)$|TestCopilotThread(CreateReturnsChatwootPayload|MessagesListAndCreateUseNestedPayloads|CreateProviderDisabledReturnsStableUnavailableAssistantMessage|CreateProviderEnabledPersistsGeneratedAssistantMessage|MessagePayloadUsesThreadPushShape|MessagePushPayloadMatchesChatwootEventData)$|TestCaptainPreferences(GetReturnsRawChatwootConfig|UpdateMergesAccountModelsAndFeatures|UpdateRejectsNonAdminAndInvalidModel)$|TestCaptainTaskHandler_(Summarize_ChatwootRawPayload|Rewrite_NoProviderRawDisabled|StreamSummarize_ChatwootDisplayID)$|TestCaptainTaskExtendedHandler_(LabelSuggestion_ChatwootPostRawPayload|FollowUp_ChatwootPostUpdatesContext)$' -count=1 +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/service -run 'TestConversationMaintenanceJobs(TriggerScheduledItemsFanOut|ProcessCampaignSnoozeAndResolution|CampaignTriggerDispatchesEvents|ConversationBulkActionQueuesSearchIndex|ContactBulkActionQueuesSearchIndexAndCompletes)$' -count=1 +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -run 'TestChatwootAuth|TestProfileHandlerSuite' +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -run 'TestChatwootAuth(SignInReturnsDeviseHeadersAndUserPayload|ValidateTokenReturnsPayloadData|SignOutRevokesRefreshSession|ResetPasswordReturnsReferenceMessage|UpdatePasswordWithResetToken)$|TestProfileHandlerSuite/(TestGet_Success|TestUpdateAvatar_Success|TestDeleteAvatar_ReturnsChatwootUserSerializer|TestSetAvailability_ReturnsChatwootUserSerializer|TestSetAutoOffline_ReturnsChatwootUserSerializer|TestSetActiveAccount_UpdatesMembershipActiveAt|TestResetAccessToken_RegeneratesTokenInChatwootUserSerializer|TestResendConfirmation_.*)$|TestFacebookCallbacks_FacebookPagesPayload' -count=1 +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 -run 'TestMFAHandlerSuite|TestAccountHandlerSuite/(TestCacheKeys_Success|TestUpdateActiveAt_Success)' +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/ws -run 'TestEventPublisher_(NotificationChatwootPayloadShape|AccountCacheInvalidatedChatwootPayloadShape|AllEventTypes)$' -count=1 +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/ws -count=1 +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/repository -run 'TestAccount(User)?Repo_UpdateActiveAt' +GOCACHE=/tmp/go-build-cache GOMODCACHE=/tmp/go-mod-cache go test ./internal/router -run TestRegisterRoutesBootsWithChatwootParityConflictGroups +GOCACHE=/tmp/go-build-cache GOMODCACHE=/tmp/go-mod-cache go test ./... +``` + +## Open Gates + +- Live `scripts/parity_frontend_smoke.sh --enterprise-browser-smoke` passed on 2026-06-13 on a fresh Docker PostgreSQL/Redis/Meilisearch stack after migrations and seed were made repeatable; the harness captures seeded conversation/message/contact/company/article search payloads as `search_conversations.json`, `search_messages.json`, `search_contacts.json`, `search_companies.json`, and `search_articles.json`. +- Live browser smoke now covers reused Chatwoot login/dashboard boot, token validation, dashboard conversations request, widget boot, widget messages/inbox-members/campaigns endpoint checks, enterprise route requests, and no frontend-visible proxied API 4xx/5xx failures; latest browser report: `.tmp/frontend-smoke-live/browser-smoke-report.json`. +- Live enterprise API and enterprise browser smoke passed after migration/seed against GoChat `127.0.0.1:13000`, reused Chatwoot Vite `localhost:3036`, PostgreSQL `15432`, Redis `16379`, and Meilisearch `17700`; browser evidence now includes 37 checks and explicit reused-frontend requests for `/api/v1/accounts/:account_id/notifications?page=1` and `/api/v1/accounts/:account_id/notification_settings`. +- Auth/profile/account fixture coverage now asserts exact frontend-visible user/account keys for sign-in, validate-token, profile, availability, auto-offline, active-account side effects, MFA raw payloads, and account cache keys. +- Inbox fixture coverage now asserts Chatwoot-style list/detail envelopes, admin-only sensitive channel fields, inbox-member payload/diff updates, and raw WhatsApp Cloud health payloads. +- Conversation/message fixture coverage now asserts Chatwoot-style list envelopes, meta sender, labels, additional/custom attributes, last message payloads, outgoing message create defaults, sender fields, content attributes, attachments, display conversation IDs, and `message.created` WS/SSE payload shape. +- Notification/cache realtime fixture coverage now asserts the Chatwoot notifications store payload shape `{ notification, unread_count, count }` for created/updated/deleted events and the `account.cache_invalidated.cache_keys` label/inbox/team payload used by `CacheEnabledApiClient` revalidation; browser-store smoke now verifies the current notification index and profile notification-preference screens request GoChat without frontend patches. +- Fixture comparisons outside auth/profile/account are still broad and should be split into owner-slice tests as each frontend inventory row is closed; the CRM row now has frozen dashboard payload-shape assertions for the common contact/company frontend flows, including companies and contacts literal-spec runtime paths, and the widget/public row now has focused config/popout/message/direct-upload/create-complete/campaign payload-shape guards. diff --git a/docs/parity/frontend_contract_inventory.md b/docs/parity/frontend_contract_inventory.md new file mode 100644 index 00000000..fcd7362a --- /dev/null +++ b/docs/parity/frontend_contract_inventory.md @@ -0,0 +1,261 @@ +# Frontend Contract Inventory + +> Generated: 2026-06-11 +> Source: `reference/chatwoot/app/javascript/dashboard/api`, `reference/chatwoot/app/javascript/widget/api`, `reference/chatwoot/app/javascript/survey/api` +> Purpose: Phase 0 deliverable for GoChat ↔ Chatwoot/Woochat direct-connect alignment. + +## Scope + +This inventory records the frontend API client contract that GoChat must satisfy without patching the reused Woochat/current Chatwoot frontend. It is intentionally route-and-owner focused; fixture tests and smoke runs should attach exact payload evidence to these rows as implementation proceeds. + +## Owner Summary + +| Owner Area | Contract Entries | +| --- | --- | +| Auth/Profile/Account | 26 | +| Automation/Enterprise | 8 | +| CRM | 25 | +| CSAT/Survey | 7 | +| Captain/Copilot | 12 | +| Conversation/Message | 27 | +| Help Center | 8 | +| Inbox/Agents/Assignment | 41 | +| Integrations/Channels | 14 | +| Other | 20 | +| Reports/Audit | 14 | +| Widget | 22 | + +## Contract Entries + +| Owner Area | Frontend File | Kind | Resource / Path | Scope | Methods Seen | GoChat Alignment Owner | Status | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Auth/Profile/Account | `reference/chatwoot/app/javascript/dashboard/api/CacheEnabledApiClient.js` | literal | `/api/v1/accounts/${this.accountIdFromRoute}/cache_keys` | explicit | GET | `internal/handler/api/v1/account_handler_test.go::TestCacheKeys_Success`; `internal/ws/event_publisher_test.go::TestEventPublisher_AccountCacheInvalidatedChatwootPayloadShape` | Covered for raw `cache_keys` response and account-cache invalidation payload used by CacheEnabledApiClient. | +| Auth/Profile/Account | `reference/chatwoot/app/javascript/dashboard/api/account.js` | literal | `/api/v1/accounts/${this.accountIdFromRoute}/cache_keys` | explicit | GET, POST | `internal/handler/api/v1/account_handler_test.go::TestCacheKeys_Success`; account update fixtures | Covered for dashboard account cache-key fetch and account mutation/update fixtures; no frontend patch required. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/accountActions.js` | ApiClient | `actions` | account-scoped | POST | `internal/handler/api/v1/contact_handler_crud_test.go::TestChatwootFrontendContactMergeImportExportContracts`; `TestMergePublishesBaseUpdateAndMergeeDeleteEvents` | Covered for Woochat contact merge payload, raw contact response, merge side effects, and contact update/delete realtime events. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/agentBots.js` | ApiClient | `agent_bots` | account-scoped | POST, PATCH, DELETE | `internal/handler/api/v1/agent_bot_handler_test.go::TestAccountChatwootPayloadsAndRoutes`; route parity exact for CRUD/avatar/reset actions | Covered for account-scoped create/list/show/update/delete, multipart-compatible bind path, raw Chatwoot bot serializer keys, avatar delete, reset access token, reset secret, and account/global bot visibility boundaries. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/agentCapacityPolicies.js` | ApiClient | `agent_capacity_policies` | account-scoped | GET, POST, PUT, DELETE | `internal/handler/api/v1/agent_capacity_handler_test.go::TestChatwootPolicyInboxLimitAndUserFlow`; `TestChatwootPolicyInboxLimitAndUserValidationErrors` | Covered for policy CRUD, nested users, nested inbox limits, Chatwoot request body keys, count fields, and frontend-safe validation/not-found envelopes. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/agents.js` | ApiClient | `agents` | account-scoped | POST | `internal/handler/api/v1/agent_handler_test.go::TestCreateAgentChatwootFrontendPayload`; agent list/order fixtures | Covered for Woochat agent create response shape, raw Chatwoot agent serializer keys, role/availability/provider fields, and no envelope wrapping. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/assignableAgents.js` | ApiClient | `assignable_agents` | account-scoped | GET | `internal/handler/api/v1/assignable_agent_handler_test.go::TestList_StandaloneResourceUsesFrontendInboxIDsQuery`; `TestList_ResponseStructure`; multi-inbox intersection tests | Covered for Woochat `inbox_ids` query shape, standalone account-scoped resource path, response structure, admin inclusion, deduplication, and multi-inbox intersection semantics. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/assignmentPolicies.js` | ApiClient | `assignment_policies` | account-scoped | GET, POST, DELETE | `internal/handler/api/v1/assignment_policy_handler_test.go::TestPluralAssignmentPolicies_ChatwootPayloads`; `TestInboxAssignmentPolicy_ChatwootRoutes` | Covered for plural assignment-policy list payloads, assigned inbox counts, policy inbox listing, and account-scoped route compatibility. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/assignmentPolicies.js` | literal | `/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy` | explicit | GET, POST, DELETE | `internal/handler/api/v1/assignment_policy_handler_test.go::TestInboxAssignmentPolicy_ChatwootRoutes` | Covered for Woochat inbox assignment-policy set/show/delete literal paths and `assignment_policy_id` payload. | +| Automation/Enterprise | `reference/chatwoot/app/javascript/dashboard/api/attributes.js` | ApiClient | `custom_attribute_definitions` | account-scoped | GET | `internal/handler/api/v1/custom_attribute_definition_handler_test.go::TestCustomAttributeDefinitionHandler_ListChatwootFrontendPayload`; filter-by-model fixtures | Covered for Woochat custom attribute list response shape, `attribute_model` filters, Chatwoot serializer keys, and raw array response. | +| Reports/Audit | `reference/chatwoot/app/javascript/dashboard/api/auditLogs.js` | ApiClient | `audit_logs` | account-scoped | GET | `internal/handler/api/v1/audit_handler_test.go::TestList_ChatwootPayloadFiltersAndSerializer`; pagination/order fixtures | Covered for audit log list filters, Chatwoot serializer keys, account scope, fixed page size, pagination meta, and descending order. | +| Automation/Enterprise | `reference/chatwoot/app/javascript/dashboard/api/automation.js` | ApiClient | `automation_rules` | account-scoped | POST | `internal/handler/api/v1/automation_rule_handler_test.go::TestChatwootFrontendCRUDCloneTogglePayloadsAndValidation` | Covered for Woochat automation CRUD, clone, toggle, Chatwoot payload keys, and validation envelopes. | +| Automation/Enterprise | `reference/chatwoot/app/javascript/dashboard/api/bulkActions.js` | ApiClient | `bulk_actions` | account-scoped | standard CRUD | `internal/handler/api/v1/bulk_action_handler_test.go::TestBulkActionHandler_ConversationEnqueuesChatwootPayload`; contact/invalid-type fixtures | Covered for conversation/contact bulk action request bodies, queued Chatwoot payloads, and invalid-type error payloads. | +| Automation/Enterprise | `reference/chatwoot/app/javascript/dashboard/api/campaigns.js` | ApiClient | `campaigns` | account-scoped | standard CRUD | `internal/handler/api/v1/campaign_handler_test.go::TestChatwootFrontendPayloadsAndLifecycleUseDisplayID`; `TestStartCreatesCampaignConversationsAndMessages` | Covered for Woochat campaign list/show/create/update/delete/start/stop payloads, display-ID lifecycle behavior, and campaign conversation/message side effects. | +| Automation/Enterprise | `reference/chatwoot/app/javascript/dashboard/api/cannedResponse.js` | ApiClient | `canned_responses` | account-scoped | GET | `internal/handler/api/v1/canned_response_handler_test.go::TestList_Success`; search/create/update/delete account-scope fixtures | Covered for Woochat canned-response list/search raw arrays, create/update raw payloads, delete empty OK, and account scoping. | +| Captain/Copilot | `reference/chatwoot/app/javascript/dashboard/api/captain/assistant.js` | ApiClient | `captain/assistants` | account-scoped | GET, POST | `internal/handler/api/v1/captain_assistant_handler_test.go::TestCaptainAssistantHandler_CRUDUsesChatwootPayloadShape`; playground fallback/provider fixtures | Covered for assistant CRUD/list/search payloads, inbox binding, playground request shape, account scope, disabled fallback, and provider-error fallback. | +| Captain/Copilot | `reference/chatwoot/app/javascript/dashboard/api/captain/bulkActions.js` | ApiClient | `captain/bulk_actions` | account-scoped | standard CRUD | `internal/handler/api/v1/captain_resource_parity_handler_test.go::TestCaptainBulkActionHandler_ChatwootResourceActions`; label-suggestion fixture | Covered for AssistantResponse approve/reject bulk action, AssistantDocument sync bulk action, invalid action envelope, and task-style label suggestion execution. | +| Captain/Copilot | `reference/chatwoot/app/javascript/dashboard/api/captain/copilotMessages.js` | ApiClient | `captain/copilot_threads` | account-scoped | GET, POST | `internal/handler/api/v1/copilot_thread_handler_test.go::TestCopilotThreadMessagesListAndCreateUseNestedPayloads`; push payload fixtures | Covered for nested copilot message list/create payloads, account/user scope, provider-disabled fallback, and Chatwoot event push shape. | +| Captain/Copilot | `reference/chatwoot/app/javascript/dashboard/api/captain/copilotThreads.js` | ApiClient | `captain/copilot_threads` | account-scoped | standard CRUD | `internal/handler/api/v1/copilot_thread_handler_test.go::TestCopilotThreadCreateReturnsChatwootPayload`; list/get/delete scope fixtures | Covered for copilot thread create/list/get/delete payloads, user ordering/scope, assistant scope validation, provider-enabled and provider-disabled flows. | +| Captain/Copilot | `reference/chatwoot/app/javascript/dashboard/api/captain/customTools.js` | ApiClient | `captain/custom_tools` | account-scoped | GET, POST, PUT, DELETE | `internal/handler/api/v1/captain_resource_parity_handler_test.go::TestCaptainCustomToolHandler_ChatwootToolPayloadsAndScope`; test-tool fixture | Covered for custom-tool list/show/create/update/delete payloads, admin-only auth config, slug generation, account scope, test-tool response, and validation envelopes. | +| Captain/Copilot | `reference/chatwoot/app/javascript/dashboard/api/captain/document.js` | ApiClient | `captain/documents` | account-scoped | GET, POST | `internal/handler/api/v1/captain_resource_parity_handler_test.go::TestCaptainDocumentHandler_ChatwootDocumentPayloadsAndSync`; service job fixtures | Covered for document list/show/create/update/delete/sync/crawl payloads, assistant filters, parser/embedding job side effects, and account scope. | +| Captain/Copilot | `reference/chatwoot/app/javascript/dashboard/api/captain/inboxes.js` | ApiClient | `captain/assistants` | account-scoped | GET, POST, DELETE | `internal/handler/api/v1/captain_assistant_handler_test.go::TestCaptainAssistantHandler_AccountScopedShowAndInboxBinding`; conversation inbox-assistant fixtures | Covered for assistant inbox binding/unbinding routes, account-scoped assistant show, and inbox assistant lookup used by conversations. | +| Captain/Copilot | `reference/chatwoot/app/javascript/dashboard/api/captain/preferences.js` | ApiClient | `captain/preferences` | account-scoped | GET, PUT | `internal/handler/api/v1/captain_preference_handler_test.go::TestCaptainPreferencesGetReturnsRawChatwootConfig`; update/validation fixtures | Covered for raw Captain preference config, account model/feature merge behavior, non-admin rejection, invalid model validation, and account ID errors. | +| Captain/Copilot | `reference/chatwoot/app/javascript/dashboard/api/captain/response.js` | ApiClient | `captain/assistant_responses` | account-scoped | GET | `internal/handler/api/v1/captain_resource_parity_handler_test.go::TestCaptainAssistantResponseHandler_ChatwootResponsePayloadsAndFilters`; process-response fixtures | Covered for assistant-response list/show/create/update/delete payloads, assistant/document/status/search filters, `Captain::Document` documentable normalization, and LLM process errors. | +| Captain/Copilot | `reference/chatwoot/app/javascript/dashboard/api/captain/scenarios.js` | ApiClient | `captain/assistants` | account-scoped | GET, POST, PUT, DELETE | `internal/handler/api/v1/captain_resource_parity_handler_test.go::TestCaptainScenarioHandler_ChatwootScenarioPayloadsAndScope` | Covered for assistant-scoped scenario create/list/show/update/delete payloads, enabled filtering, nested assistant payload, and account scope. | +| Captain/Copilot | `reference/chatwoot/app/javascript/dashboard/api/captain/tasks.js` | ApiClient | `captain/tasks` | account-scoped | POST | `internal/handler/api/v1/captain_task_handler_test.go::TestCaptainTaskHandler_Summarize_ChatwootRawPayload`; extended task label/follow-up fixtures | Covered for summarize/rewrite raw payloads, stream SSE shape, display-ID conversation lookup, disabled-provider response, label suggestion, and follow-up context update payloads. | +| Captain/Copilot | `reference/chatwoot/app/javascript/dashboard/api/captain/tools.js` | ApiClient | `captain/assistants/tools` | account-scoped | GET | `internal/handler/api/v1/captain_resource_parity_handler_test.go::TestCaptainCustomToolHandler_ChatwootToolPayloadsAndScope`; assistant tools route fixtures | Covered for assistant tools listing backed by account-scoped custom tools and Chatwoot custom tool serializer shape. | +| Other | `reference/chatwoot/app/javascript/dashboard/api/changelog.js` | ApiClient | `changelog` | global | GET | `reference/chatwoot/app/javascript/shared/constants/links.js::CHANGELOG_API_URL` | Not a GoChat service contract: Woochat calls the external Hub URL `https://hub.2.chatwoot.com/changelogs` directly via `fetchFromHub`; GoChat does not need an API-compatible backend route for direct-connect. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/channel/fbChannel.js` | ApiClient | `facebook_indicators` | account-scoped | POST | `internal/handler/api/v1/facebook_callbacks_handler_test.go::TestFacebookCallbacks_RegisterFacebookPage`; `TestFacebookCallbacks_ReauthorizePage` | Covered for Facebook page registration and reauthorization payloads used by Woochat `callbacks/register_facebook_page` and `callbacks/reauthorize_page`. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/channel/googleClient.js` | ApiClient | `google` | account-scoped | POST | `internal/handler/api/v1/email_oauth_authorization_test.go::TestGoogleAuthorization_ReturnsChatwootPayload`; root OAuth callback fixtures | Covered for Google authorization URL/state payloads and callback redirect/config persistence used by Woochat email channel setup. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/channel/instagramClient.js` | ApiClient | `instagram` | account-scoped | POST | `internal/handler/api/v1/social_authorization_test.go::TestInstagramAuthorization_ReturnsChatwootPayload`; root OAuth callback fixtures | Covered for Instagram authorization URL/state payloads, callback config persistence/upsert, and settings redirect behavior. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/channel/microsoftClient.js` | ApiClient | `microsoft` | account-scoped | POST | `internal/handler/api/v1/email_oauth_authorization_test.go::TestMicrosoftAuthorization_ReturnsChatwootPayload`; email OAuth callback error fixtures | Covered for Microsoft authorization URL/state payloads and callback provider-error frontend redirects without phantom inbox creation. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/channel/tiktokClient.js` | ApiClient | `tiktok` | account-scoped | POST | `internal/handler/api/v1/tiktok_channel_handler_test.go::TestTikTokChannel_Create_ChatwootSetupPayloadAndConfig`; root OAuth callback fixtures | Covered for TikTok authorization/setup payloads, persisted channel config, callback upsert, and settings redirect behavior. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/channel/twilioChannel.js` | ApiClient | `channels/twilio_channel` | account-scoped | standard CRUD | `internal/handler/api/v1/twilio_channel_handler_test.go::TestTwilioChannel_Create_Success`; Twilio webhook/status fixtures | Covered for Twilio channel create raw inbox payload, persisted channel config/linkage, inbound callback, and delivered/read/failed status webhook behavior. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/channel/twitterClient.js` | ApiClient | `twitter` | account-scoped | POST | `internal/handler/api/v1/twitter_authorization_handler_test.go::TestTwitterAuthorization_ReturnsChatwootPayload`; root OAuth callback fixtures | Covered for Twitter authorization URL/state payloads, signed state callback matching, callback config persistence/upsert, and settings redirect behavior. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js` | ApiClient | `voice` | account-scoped | standard CRUD | `internal/router/router_test.go::TestTwilioVoiceRoutesServeConferenceAndPersistCallbacks`; `internal/handler/api/v1/contact_handler_crud_test.go::TestContactHandlerCRUDTestSuite/TestInitiateCall_CreatesConversationCallAndVoiceMessage` | Covered for Woochat voice client token/join/leave conference routes and contact initiate-call payload used by `useCallSession`; provider calls remain feature-gated by configured Twilio credentials. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/channel/webChannel.js` | ApiClient | `inboxes` | account-scoped | standard CRUD | `internal/handler/api/v1/inbox_handler_parity_test.go::TestInboxHandler_ChatwootSerializerParity`; widget/admin config fixtures | Covered for web-widget inbox create/list/show/update/delete serializer shape, channel settings, web widget config, and sensitive-field role gating. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js` | ApiClient | `whatsapp_calls` | account-scoped | GET, POST | `internal/handler/api/v1/whatsapp_call_handler_test.go::TestWhatsAppCallHandler_AccountRoutesMatchFrontendAPI`; action/recording fixtures | Covered for WhatsApp call show/initiate/accept/reject/terminate/upload-recording account routes and frontend payloads. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/channel/whatsappChannel.js` | ApiClient | `whatsapp` | account-scoped | POST | `internal/service/whatsapp_authorization_service_test.go::TestWhatsAppAuthorization_CreateEmbeddedSignupInbox`; `TestWhatsAppAuthorization_ReauthorizesExistingInbox`; handler route parity | Covered for Woochat embedded signup and reauthorization POST `/whatsapp/authorization` payloads, success envelope, inbox linkage, and not-found/error envelopes. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/companies.js` | ApiClient | `companies` | account-scoped | GET, POST, DELETE | `internal/handler/api/v1/crm_frontend_smoke_test.go::TestChatwootFrontendCRMSmoke`; `internal/handler/api/v1/company_handler_test.go` | Covered for Woochat company list/search/show/create/update, nested contacts/search/add/remove, conversations, notes, custom-attribute deletion, avatar deletion, raw payloads, pagination, and account scope. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/contactNotes.js` | ApiClient | `notes` | account-scoped | standard CRUD | `internal/handler/api/v1/crm_frontend_smoke_test.go::TestChatwootFrontendCRMSmoke`; `internal/handler/api/v1/contact_handler_crud_test.go` note CRUD fixtures | Covered for Woochat contact notes list/create/delete route shape, raw note payloads, and contact/account scoping. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/contacts.js` | ApiClient | `contacts` | account-scoped | GET, POST, PATCH, DELETE | `internal/handler/api/v1/crm_frontend_smoke_test.go::TestChatwootFrontendCRMSmoke`; `internal/handler/api/v1/contact_handler_crud_test.go::TestChatwootFrontendContactMergeImportExportContracts` | Covered for Woochat contact list/search/filter/active/show/create/update, labels, conversations, contactable inboxes, call initiation, import/export, merge, custom-attribute deletion, avatar deletion, raw payloads, pagination, and account scope. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/conversations.js` | ApiClient | `conversations` | account-scoped | GET, POST | `internal/handler/api/v1/conversation_handler_test.go::TestUnreadCounts_Success`; `internal/handler/api/v1/conversation_handler_crud_test.go::TestConversationCrudTestSuite/TestUpdateLabels_Success`; message/realtime fixtures | Covered for Woochat conversation labels get/update, unread counts, core list/create/message serializer payloads, and realtime message/conversation event shapes. | +| CSAT/Survey | `reference/chatwoot/app/javascript/dashboard/api/csatReports.js` | ApiClient | `csat_survey_responses` | account-scoped | GET | `internal/handler/api/v1/csat_survey_handler_test.go::TestCsatSurveyHandlerTestSuite/TestCSATFrontendContractShapes`; `internal/handler/api/v1/csat_metrics_handler_test.go` | Covered for Woochat report list/metrics/download params (`since`, `until`, `user_ids`, `inbox_id`, `team_id`, `rating`, `sort=-created_at`), payload shape, CSV download, and review-note update. | +| Automation/Enterprise | `reference/chatwoot/app/javascript/dashboard/api/customRole.js` | ApiClient | `custom_roles` | account-scoped | standard CRUD | `internal/handler/api/v1/custom_role_handler_test.go::TestCustomRoleHandlerSuite`; permission/profile fixtures | Covered for Woochat custom-role list/create/show/update/delete raw payloads, permission serialization, account scope, and profile permission projection. | +| Automation/Enterprise | `reference/chatwoot/app/javascript/dashboard/api/customViews.js` | ApiClient | `custom_filters` | account-scoped | GET, DELETE | `internal/handler/api/v1/custom_filter_handler_test.go::TestCustomFilterHandlerSuite` | Covered for Woochat custom-view list by `filter_type`, create/show/update/delete payloads, and account-scoped errors. | +| Other | `reference/chatwoot/app/javascript/dashboard/api/dashboardApps.js` | ApiClient | `dashboard_apps` | account-scoped | standard CRUD | `internal/handler/api/v1/dashboard_app_handler_test.go::TestDashboardAppHandlerSuite` | Covered for Woochat dashboard-app list/create/show/update/delete raw payloads, raw request bodies, 204 delete, and account scope. | +| Auth/Profile/Account | `reference/chatwoot/app/javascript/dashboard/api/endPoints.js` | literal | `/api/v1/profile` | explicit | - | `internal/handler/api/v1/profile_handler_test.go::TestProfileHandlerSuite/TestGet_Success`; update fixtures | Covered for Woochat profile bootstrap and profile update payloads, user/account serializer keys, settings JSON, password update validation, and multipart profile form parity. | +| Auth/Profile/Account | `reference/chatwoot/app/javascript/dashboard/api/endPoints.js` | literal | `/api/v1/profile/auto_offline` | explicit | - | `internal/handler/api/v1/profile_handler_test.go::TestProfileHandlerSuite/TestSetAutoOffline_ReturnsChatwootUserSerializer` | Covered for Woochat auto-offline mutation request and raw Chatwoot user serializer response. | +| Auth/Profile/Account | `reference/chatwoot/app/javascript/dashboard/api/endPoints.js` | literal | `/api/v1/profile/availability` | explicit | - | `internal/handler/api/v1/profile_handler_test.go::TestProfileHandlerSuite/TestSetAvailability_ReturnsChatwootUserSerializer` | Covered for Woochat availability mutation request and raw Chatwoot user serializer response. | +| Auth/Profile/Account | `reference/chatwoot/app/javascript/dashboard/api/endPoints.js` | literal | `/api/v1/profile/avatar` | explicit | - | `internal/handler/api/v1/profile_handler_test.go::TestProfileHandlerSuite/TestUpdateAvatar_Success`; `TestDeleteAvatar_ReturnsChatwootUserSerializer` | Covered for Woochat profile avatar update/delete calls, raw Chatwoot user serializer response, persisted avatar clearing, and validation errors. | +| Auth/Profile/Account | `reference/chatwoot/app/javascript/dashboard/api/endPoints.js` | literal | `/api/v1/profile/resend_confirmation` | explicit | - | `internal/handler/api/v1/profile_handler_test.go::TestProfileHandlerSuite/TestResendConfirmation_DoesNotSendForConfirmedUser`; invited/unconfirmed variants | Covered for Woochat resend-confirmation success payloads and confirmed, unconfirmed, and invited-user mail side effects. | +| Auth/Profile/Account | `reference/chatwoot/app/javascript/dashboard/api/endPoints.js` | literal | `/api/v1/profile/reset_access_token` | explicit | - | `internal/handler/api/v1/profile_handler_test.go::TestProfileHandlerSuite/TestResetAccessToken_RegeneratesTokenInChatwootUserSerializer` | Covered for Woochat reset-access-token mutation and regenerated token in raw Chatwoot user serializer response. | +| Auth/Profile/Account | `reference/chatwoot/app/javascript/dashboard/api/endPoints.js` | literal | `/api/v1/profile/set_active_account` | explicit | - | `internal/handler/api/v1/profile_handler_test.go::TestProfileHandlerSuite/TestSetActiveAccount_UpdatesMembershipActiveAt`; repository active-at fixtures | Covered for Woochat active-account mutation payload, membership `active_at` persistence, and user serializer response. | +| Auth/Profile/Account | `reference/chatwoot/app/javascript/dashboard/api/endPoints.js` | literal | `/auth/validate_token` | explicit | - | `internal/handler/api/v1/auth_handler_test.go::TestChatwootAuthValidateTokenReturnsPayloadData`; auth middleware token-header fixtures | Covered for DeviseTokenAuth-compatible validate-token payload, exposed token headers, user data, and account membership keys. | +| Auth/Profile/Account | `reference/chatwoot/app/javascript/dashboard/api/endPoints.js` | literal | `api/v1/accounts.json` | explicit | - | `reference/chatwoot/app/javascript/dashboard/api/specs/endPoints.spec.js` only; frontend import grep | Not a current GoChat service contract: the `register` endpoint constant is only asserted by the frontend unit spec and no reused dashboard runtime module imports `endPoints('register')`. | +| Auth/Profile/Account | `reference/chatwoot/app/javascript/dashboard/api/endPoints.js` | literal | `api/v1/accounts/${accountId}/callbacks/facebook_pages.json` | explicit | - | `internal/handler/api/v1/facebook_callbacks_handler_test.go::TestFacebookCallbacks_FacebookPagesPayload` | Covered for Woochat Facebook page picker callback payload `{data:{page_details,user_access_token}}` and existing-page flags. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/endPoints.js` | literal | `api/v1/conversations.json` | explicit | - | `reference/chatwoot/app/javascript/dashboard/api/specs/endPoints.spec.js` only; frontend import grep | Not a current GoChat service contract: the legacy `me`/`getInbox` endpoint constants are only asserted by the frontend unit spec; runtime conversation clients use account-scoped `/api/v1/accounts/:account_id/conversations`. | +| Auth/Profile/Account | `reference/chatwoot/app/javascript/dashboard/api/endPoints.js` | literal | `auth/password` | explicit | - | `internal/handler/api/v1/auth_handler_test.go::TestChatwootAuthResetPasswordReturnsReferenceMessage`; password update fixtures | Covered for Woochat reset-password request message, token persistence, and password update/reset-token validation. | +| Auth/Profile/Account | `reference/chatwoot/app/javascript/dashboard/api/endPoints.js` | literal | `auth/sign_out` | explicit | - | `internal/handler/api/v1/auth_handler_test.go::TestChatwootAuthSignOutRevokesRefreshSession` | Covered for Woochat sign-out route, refresh-session revocation, and empty success response behavior. | +| Auth/Profile/Account | `reference/chatwoot/app/javascript/dashboard/api/enterprise/specs/account.spec.js` | literal | `/enterprise/api/v1/checkout` | explicit | - | auth/profile/account handlers and account cache routes | Fixture covered by `TestEnterpriseAccountLiteralFrontendPathsUseCurrentAccountContext` | +| Auth/Profile/Account | `reference/chatwoot/app/javascript/dashboard/api/enterprise/specs/account.spec.js` | literal | `/enterprise/api/v1/limits` | explicit | - | auth/profile/account handlers and account cache routes | Fixture covered by `TestEnterpriseAccountLiteralFrontendPathsUseCurrentAccountContext` | +| Auth/Profile/Account | `reference/chatwoot/app/javascript/dashboard/api/enterprise/specs/account.spec.js` | literal | `/enterprise/api/v1/subscription` | explicit | - | auth/profile/account handlers and account cache routes | Fixture covered by `TestEnterpriseAccountLiteralFrontendPathsUseCurrentAccountContext` | +| Auth/Profile/Account | `reference/chatwoot/app/javascript/dashboard/api/enterprise/specs/account.spec.js` | literal | `/enterprise/api/v1/toggle_deletion` | explicit | - | auth/profile/account handlers and account cache routes | Fixture covered by `TestEnterpriseAccountLiteralFrontendPathsUseCurrentAccountContext` | +| Auth/Profile/Account | `reference/chatwoot/app/javascript/dashboard/api/enterprise/specs/account.spec.js` | literal | `/enterprise/api/v1/topup_checkout` | explicit | - | auth/profile/account handlers and account cache routes | Fixture covered by `TestEnterpriseAccountLiteralFrontendPathsUseCurrentAccountContext` | +| Help Center | `reference/chatwoot/app/javascript/dashboard/api/helpCenter/articles.js` | ApiClient | `articles` | account-scoped | GET, POST, PATCH, DELETE | `internal/handler/api/v1/article_handler_test.go::TestArticleHandlerSuite/TestDashboardArticleCRUD_ChatwootFrontendPayloadsAndRoutes`; bulk/reorder fixtures | Covered for Woochat dashboard article list/search/show/create/update/delete, slug-scoped portal routes, raw frontend body mapping, reorder, bulk translate/status/category/delete actions, public JSON/markdown/pixel side effects, and account scope. | +| Help Center | `reference/chatwoot/app/javascript/dashboard/api/helpCenter/categories.js` | ApiClient | `categories` | account-scoped | GET, POST, PATCH, DELETE | `internal/handler/api/v1/category_handler_test.go::TestCategoryHandlerSuite/TestCreate_RawFrontendPayloadAndSlugPortal`; list/update/delete/reorder fixtures | Covered for Woochat slug-scoped category list/create/update/delete/reorder, locale filtering, raw frontend payloads, and public category payloads. | +| Help Center | `reference/chatwoot/app/javascript/dashboard/api/helpCenter/portals.js` | ApiClient | `portals` | account-scoped | GET, POST, PATCH, DELETE | `internal/handler/api/v1/portal_handler_test.go::TestPortalHandlerSuite/TestList_ReturnsChatwootPayloadEnvelope`; update/delete/send-instructions fixtures | Covered for Woochat portal list/get/update/delete-by-slug, logo/instructions/status-compatible routes, Chatwoot payload envelope, public redirect/get/sitemap, and archived portal filtering. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/inbox/conversation.js` | ApiClient | `conversations` | account-scoped | GET, POST, PATCH, DELETE | `internal/handler/api/v1/conversation_handler_crud_test.go::TestConversationCrudTestSuite/TestList_Success`; label/status/assignment/snooze/filter fixtures | Covered for Woochat inbox conversation list/show/create/update/delete, filters/meta/search, labels, mute/unmute, status toggles, snooze, priority, team/agent assignment, transcript, and display-ID route behavior. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/inbox/message.js` | ApiClient | `conversations` | account-scoped | GET, POST, DELETE | `internal/handler/api/v1/message_handler_test.go::TestMessageHandlerTestSuite/TestList_Success`; create/delete/retry/translate fixtures | Covered for Woochat message list/create/delete/retry/translate, route conversation precedence, outgoing defaults, multipart attachments, content attributes, sender fields, and Chatwoot serializer shape. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/inbox/message.js` | literal | `${this.url}/${conversationId}/messages` | explicit | GET, POST, DELETE | `internal/handler/api/v1/message_handler_test.go::TestMessageHandlerTestSuite/TestList_Success`; create/delete fixtures | Covered for literal nested Woochat message path, list pagination, create payloads, multipart uploads, delete response, and invalid conversation errors. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/inboxHealth.js` | ApiClient | `inboxes` | account-scoped | GET, POST | `internal/handler/api/v1/inbox_handler_parity_test.go::TestInboxHandler_HealthReturnsWhatsAppCloudRawPayload`; provider failure fixture | Covered for Woochat inbox health success/failure payloads, WhatsApp Cloud raw fields, provider failure state, and account/inbox param validation. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/inboxMembers.js` | ApiClient | `inbox_members` | account-scoped | PATCH | `internal/handler/api/v1/inbox_member_handler_test.go::TestInboxMemberHandlerSuite/TestAccountScopedInboxMembers_ChatwootPayloadAndDiffUpdate` | Covered for Woochat inbox member list/update payloads, diff semantics, account-scoped route, and assigned-agent serializer keys. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/inboxes.js` | ApiClient | `inboxes` | account-scoped | GET, POST, DELETE | `internal/handler/api/v1/inbox_handler_parity_test.go::TestInboxHandler_ChatwootSerializerParity`; create/update binding and avatar/campaign fixtures | Covered for Woochat inbox list/show/create/update/delete, serializer shape, channel settings, sensitive field gating, create/update request binding, avatar delete validation, campaigns, and sync-template queued response. | +| Integrations/Channels | `reference/chatwoot/app/javascript/dashboard/api/integrations/dyte.js` | ApiClient | `integrations/dyte` | account-scoped | POST | `internal/handler/api/v1/dyte_integration_handler_test.go::TestDyteIntegrationHandlerCreateMeetingReturnsMessagePayload`; participant fixture | Covered for Woochat Dyte create-meeting and add-participant payloads, integration message content attributes, auth token response, and invalid message error. | +| Integrations/Channels | `reference/chatwoot/app/javascript/dashboard/api/integrations/linear.js` | ApiClient | `integrations/linear` | account-scoped | GET, POST | `internal/service/linear_integration_service_test.go::TestLinearIntegrationService_ChatwootFrontendPayloads`; handler validation fixtures | Covered for Woochat Linear teams, team entities, create/link/unlink issue, linked issues, search issue payloads, activity side effects, and provider error envelopes. | +| Integrations/Channels | `reference/chatwoot/app/javascript/dashboard/api/integrations/shopify.js` | ApiClient | `integrations/shopify` | account-scoped | GET | `internal/handler/api/v1/shopify_integration_handler_test.go::TestShopifyIntegration_Auth_ReturnsChatwootRedirectPayload`; validation fixtures | Covered for Woochat Shopify auth redirect payload, OAuth query fields, configured callback URL, provider-not-configured/validation envelopes; orders live provider path remains credential-gated. | +| Integrations/Channels | `reference/chatwoot/app/javascript/dashboard/api/integrations.js` | ApiClient | `integrations/apps` | account-scoped | GET, POST, PATCH, DELETE | `internal/handler/api/v1/integration_hook_handler_suite_test.go::TestIntegrationHookHandlerSuite/TestChatwootFrontendAppsAndHooksNoTrailingSlashPayloads`; Slack/Shopify/service fixtures | Covered for Woochat integrations apps list, hook create/delete, Slack connect/update/list-channels/delete routes, Shopify auth redirect payload, raw app/hook response shapes, no-trailing-slash frontend paths, and provider API behavior via fake Slack transport where needed. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/labels.js` | ApiClient | `labels` | account-scoped | standard CRUD | `internal/handler/api/v1/label_handler_test.go::TestLabelHandlerSuite/TestListTags_Success`; create/update/delete fixtures | Covered for Woochat label list/create/update/delete payloads, raw name compatibility, account scoping, and conversation label association routes. | +| Reports/Audit | `reference/chatwoot/app/javascript/dashboard/api/liveReports.js` | ApiClient | `live_reports` | account-scoped | GET | `internal/handler/api/v1/live_report_handler_test.go::TestLiveReportHandlerTestSuite/TestAPIV2LiveReports_ChatwootFrontendPayloadShapes`; store-refresh fixture | Covered for Woochat API v2 live report conversation metrics, grouped metrics, team filters, Chatwoot error shape, and store refresh request sequence. | +| Automation/Enterprise | `reference/chatwoot/app/javascript/dashboard/api/macros.js` | ApiClient | `macros` | account-scoped | POST | `internal/handler/api/v1/macro_handler_test.go::TestMacroHandlerSuite/TestExecute_ChatwootFrontendAwaitsEmptyOKAndSupportsSingleConversationID`; CRUD fixtures | Covered for Woochat macro CRUD payloads, attachment action signed-upload serialization, execute route with single/multiple conversation IDs, display-ID mutation semantics, and empty OK response. | +| Auth/Profile/Account | `reference/chatwoot/app/javascript/dashboard/api/mfa.js` | ApiClient | `profile/mfa` | account-scoped | POST, DELETE | `internal/handler/api/v1/mfa_handler_test.go::TestMFAHandlerSuite/TestProfileMFA_EnableVerifyBackupAndDisableUseFrontendPayloads`; status fixture | Covered for Woochat MFA status/enable/verify/backup-code/disable payloads, raw Chatwoot response shapes, and validation errors. | +| Other | `reference/chatwoot/app/javascript/dashboard/api/notificationSubscription.js` | ApiClient | `notification_subscriptions` | global | standard CRUD | `internal/handler/api/v1/notification_subscription_handler_test.go::TestNotificationSubscriptionCreateAcceptsPushHelperPayload`; destroy fixtures | Covered for Woochat push-helper create/update/destroy payloads, global and account-scoped routes, push-token deletion, Rails wrapper body, and empty OK delete behavior. | +| Other | `reference/chatwoot/app/javascript/dashboard/api/notifications.js` | ApiClient | `notifications` | account-scoped | GET, POST, DELETE | `internal/handler/api/v1/notification_handler_test.go::TestNotificationHandler_List_ChatwootEnvelopeAndIncludes`; mutation/realtime fixtures | Covered for Woochat notification list envelope, actor serialization, unread/read/snooze/delete/destroy-all mutations, account/user scoping, and Chatwoot realtime payloads. | +| Auth/Profile/Account | `reference/chatwoot/app/javascript/dashboard/api/notion_auth.js` | ApiClient | `notion` | account-scoped | POST | `internal/handler/api/v1/notion_integration_handler_test.go::TestNotionIntegration_Authorization_ReturnsChatwootPayload`; delete fixture | Covered for Woochat Notion authorization URL payload, redirect URI, config validation, and integration delete empty OK behavior. | +| Auth/Profile/Account | `reference/chatwoot/app/javascript/dashboard/api/onboarding.js` | ApiClient | `onboarding` | account-scoped | PATCH | `internal/handler/api/v1/account_handler_test.go::TestUpdateOnboarding_Success`; validation fixtures | Covered for Woochat onboarding step PATCH route, account-scoped update side effect, response payload, and invalid-step validation. | +| Reports/Audit | `reference/chatwoot/app/javascript/dashboard/api/reports.js` | ApiClient | `reports` | account-scoped | GET | `internal/handler/api/v1/analytics_handler_test.go::TestAnalyticsHandlerTestSuite/TestAPIV2Reports_AllFrontendEndpointShapes`; report CSV/value fixtures | Covered for Woochat reports API v2 index/summary/conversations/agents/inboxes/labels/teams/conversations_summary/conversation_traffic/bot_metrics/bot_summary endpoint shapes, timezone/value parity, CSV downloads, and outgoing-message grouped counts. | +| Other | `reference/chatwoot/app/javascript/dashboard/api/samlSettings.js` | ApiClient | `saml_settings` | account-scoped | GET, POST, PUT, DELETE | `internal/handler/api/v1/account_saml_settings_handler_test.go::TestAccountSamlSettingsHandlerTestSuite/TestChatwootFrontendCollectionCRUDPayloads`; route registration uses collection paths | Covered for Woochat SAML settings collection GET/POST/PUT/DELETE, `{ saml_settings: ... }` request wrapper, raw frontend-readable response payload, alias fields `sso_url`/`certificate`, and backward-compatible id routes. | +| Other | `reference/chatwoot/app/javascript/dashboard/api/search.js` | ApiClient | `search` | account-scoped | GET | `internal/handler/api/v1/search_handler_test.go::TestSearchHandler_GlobalSearch_UsesReferenceResultTypes`; live Meilisearch smoke evidence | Covered for Woochat global search result types, conversations/messages/contacts/articles search payloads, reference filters, hydrated message/article payloads, and live Meilisearch smoke. | +| Automation/Enterprise | `reference/chatwoot/app/javascript/dashboard/api/sla.js` | ApiClient | `sla_policies` | account-scoped | standard CRUD | `internal/handler/api/v1/sla_policy_handler_test.go::TestSlaPolicyHandler_List_Success`; CRUD/inbox association fixtures | Covered for Woochat SLA policy list/create/show/update/delete, validation, audit entries, inbox association payloads, and side effects. | +| Reports/Audit | `reference/chatwoot/app/javascript/dashboard/api/slaReports.js` | ApiClient | `applied_slas` | account-scoped | GET | `internal/handler/api/v1/sla_policy_handler_test.go::TestSlaPolicyHandler_ListAppliedSlas_ChatwootPayloadAndFilters`; metrics/download fixtures | Covered for Woochat applied-SLA list filters, metrics report shape, download payload, and service-level missed/hit report filters. | +| Auth/Profile/Account | `reference/chatwoot/app/javascript/dashboard/api/specs/account.spec.js` | literal | `/api/v1/accounts` | explicit | - | `internal/handler/api/v1/account_handler_test.go::TestAccountHandlerTestSuite/TestCreate_Success`; router route fixture | Covered for Woochat account create literal path `POST /api/v1/accounts`, no-trailing-slash route registration, Chatwoot `data.account_id` response shape, and `account_name` alias. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/specs/accountActions.spec.js` | literal | `/api/v1/actions/contact_merge` | explicit | - | `internal/handler/api/v1/contact_handler_crud_test.go::TestChatwootFrontendContactMergeImportExportContracts` | Covered for frontend merge request body and Chatwoot raw contact response on the account-scoped actions path; bare spec path remains a frontend unit-test artifact without account context. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/specs/agentCapacityPolicies.spec.js` | literal | `/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits` | explicit | - | `internal/handler/api/v1/agent_capacity_handler_test.go::TestAgentCapacityHandlerSuite/TestChatwootPolicyInboxLimitAndUserFlow`; route dump | Covered for Woochat create inbox-limit literal path, raw `inbox_id`/`conversation_limit` payload, duplicate validation, and registered account-scoped route. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/specs/agentCapacityPolicies.spec.js` | literal | `/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits/789` | explicit | - | `internal/handler/api/v1/agent_capacity_handler_test.go::TestAgentCapacityHandlerSuite/TestChatwootPolicyInboxLimitAndUserFlow`; validation fixture | Covered for Woochat update/delete inbox-limit literal paths, PUT/PATCH mutation payloads, raw updated limit serializer, and delete empty OK behavior. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/specs/agentCapacityPolicies.spec.js` | literal | `/api/v1/accounts/1/agent_capacity_policies/123/users` | explicit | - | `internal/handler/api/v1/agent_capacity_handler_test.go::TestAgentCapacityHandlerSuite/TestChatwootPolicyInboxLimitAndUserFlow`; validation fixture | Covered for Woochat capacity-policy get-users/add-user literal paths, `user_id` request body, raw assigned user payload, and list serializer. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/specs/agentCapacityPolicies.spec.js` | literal | `/api/v1/accounts/1/agent_capacity_policies/123/users/456` | explicit | - | `internal/handler/api/v1/agent_capacity_handler_test.go::TestAgentCapacityHandlerSuite/TestChatwootPolicyInboxLimitAndUserFlow`; validation fixture | Covered for Woochat remove-user literal path, account/policy scoped deletion, post-delete list behavior, and not-found validation. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/specs/agents.spec.js` | literal | `/api/v1/agents/bulk_create` | explicit | - | `internal/handler/api/v1/agent_handler_test.go::TestAgentHandlerTestSuite/TestBulkCreate`; grep of runtime callers | Covered as a frontend unit-test-only literal: runtime `agents.js` is account-scoped under `/api/v1/accounts/:account_id/agents/bulk_create`; GoChat route and bulk-create payload/side effects are covered. | +| Help Center | `reference/chatwoot/app/javascript/dashboard/api/specs/article.spec.js` | literal | `/api/v1/portals/room-rental/articles/1` | explicit | - | `internal/handler/api/v1/article_handler_test.go::TestArticleHandlerSuite/TestDashboardArticleCRUD_ChatwootFrontendPayloadsAndRoutes`; runtime grep | Covered as frontend spec-only bare URL; runtime `helpCenter/articles.js` is account-scoped under `/api/v1/accounts/:account_id/portals/:portal_slug/articles/:id`, with show/update/delete payloads covered. | +| Help Center | `reference/chatwoot/app/javascript/dashboard/api/specs/article.spec.js` | literal | `/api/v1/portals/room-rental/articles/bulk_actions/update_category` | explicit | - | `internal/handler/api/v1/article_handler_test.go::TestArticleHandlerSuite/TestBulkActions_FrontendRoutes`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped bulk update-category route and `{ ids, category_id }` request body are fixture-covered. | +| Help Center | `reference/chatwoot/app/javascript/dashboard/api/specs/article.spec.js` | literal | `/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1` | explicit | - | `internal/handler/api/v1/article_handler_test.go::TestArticleHandlerSuite/TestDashboardArticleCRUD_ChatwootFrontendPayloadsAndRoutes`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped article list supports portal slug, status, locale, category/sort filters, payload array, and meta counts. | +| Help Center | `reference/chatwoot/app/javascript/dashboard/api/specs/article.spec.js` | literal | `/api/v1/portals/room-rental/articles?query=test` | explicit | - | `internal/handler/api/v1/article_handler_test.go::TestArticleHandlerSuite/TestPublicSearch_ReturnsPublishedLocaleSearchArticlePayload`; runtime grep | Covered as frontend spec-only bare URL; runtime dashboard search popover and public help-center search routes return published locale-filtered search payloads with meta. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/specs/assignableAgents.spec.js` | literal | `/api/v1/assignable_agents` | explicit | GET | `internal/handler/api/v1/assignable_agent_handler_test.go::TestList_StandaloneResourceUsesFrontendInboxIDsQuery` | Covered as frontend unit-test URL shorthand; runtime route is account-scoped `/api/v1/accounts/:account_id/assignable_agents` with identical `inbox_ids[]` query semantics. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/specs/assignmentPolicies.spec.js` | literal | `/api/v1/accounts/1/assignment_policies/123/inboxes` | explicit | - | `internal/handler/api/v1/assignment_policy_handler_test.go::TestInboxAssignmentPolicy_ChatwootRoutes` | Covered for policy inbox listing path generated by Woochat assignment policy API. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/specs/assignmentPolicies.spec.js` | literal | `/api/v1/accounts/1/inboxes/456/assignment_policy` | explicit | - | `internal/handler/api/v1/assignment_policy_handler_test.go::TestInboxAssignmentPolicy_ChatwootRoutes` | Covered for inbox assignment-policy set/show/delete paths and `assignment_policy_id` payload generated by Woochat. | +| Automation/Enterprise | `reference/chatwoot/app/javascript/dashboard/api/specs/automation.spec.js` | literal | `/api/v1/automation_rules` | explicit | - | `internal/handler/api/v1/automation_rule_handler_test.go::TestAutomationRuleHandlerTestSuite/TestChatwootFrontendCRUDCloneTogglePayloadsAndValidation`; route dump | Covered as frontend spec-only bare URL; runtime `automation.js` is account-scoped under `/api/v1/accounts/:account_id/automation_rules`, with list/create/update/delete/clone/toggle payloads covered. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/specs/channel/fbChannel.spec.js` | literal | `/api/v1/callbacks/reauthorize_page` | explicit | - | `internal/handler/api/v1/facebook_callbacks_handler_test.go::TestFacebookCallbacks_RegisterFacebookPage` | Covered as frontend unit-test shorthand; runtime route is account-scoped `/api/v1/accounts/:account_id/callbacks/reauthorize_page` with the same `omniauth_token`/`inbox_id` payload. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/specs/channel/fbChannel.spec.js` | literal | `/api/v1/callbacks/register_facebook_page` | explicit | - | `internal/handler/api/v1/facebook_callbacks_handler_test.go::TestFacebookCallbacks_RegisterFacebookPage` | Covered as frontend unit-test shorthand; runtime route is account-scoped `/api/v1/accounts/:account_id/callbacks/register_facebook_page` with Chatwoot page registration payload. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/specs/companies.spec.js` | literal | `/api/v1/companies/1/avatar` | explicit | GET | `internal/handler/api/v1/company_handler_test.go::TestCompanyHandlerSuite/TestChatwootCompanyFrontendLiteralSpecRuntimeRoutes`; route dump | Covered as account-scoped runtime path `/api/v1/accounts/:account_id/companies/:company_id/avatar`; delete-avatar raw company payload is covered by `TestCompanyHandlerSuite/TestChatwootCompanyFrontendLiteralSpecRuntimeRoutes` and route dump. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/specs/companies.spec.js` | literal | `/api/v1/companies/1/contacts` | explicit | GET | `internal/handler/api/v1/company_handler_test.go::TestCompanyHandlerSuite/TestChatwootCompanyFrontendLiteralSpecRuntimeRoutes`; route dump | Covered as account-scoped runtime path `/api/v1/accounts/:account_id/companies/:company_id/contacts`; list/create with `{ contact_id }` body and payload/meta shape are covered by `TestCompanyHandlerSuite/TestChatwootCompanyFrontendLiteralSpecRuntimeRoutes`. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/specs/companies.spec.js` | literal | `/api/v1/companies/1/contacts/2` | explicit | GET | `internal/handler/api/v1/company_handler_test.go::TestCompanyHandlerSuite/TestChatwootCompanyFrontendLiteralSpecRuntimeRoutes`; route dump | Covered as account-scoped runtime path `/api/v1/accounts/:account_id/companies/:company_id/contacts/:contact_id`; unlink empty OK and side effect are covered by `TestCompanyHandlerSuite/TestChatwootCompanyFrontendLiteralSpecRuntimeRoutes`. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/specs/companies.spec.js` | literal | `/api/v1/companies/1/contacts/search?q=jane+%26+co&page=3` | explicit | GET | `internal/handler/api/v1/company_handler_test.go::TestCompanyHandlerSuite/TestChatwootCompanyFrontendLiteralSpecRuntimeRoutes`; route dump | Covered as account-scoped runtime path `/api/v1/accounts/:account_id/companies/:company_id/contacts/search`; encoded query/page response envelope is covered by `TestCompanyHandlerSuite/TestChatwootCompanyFrontendLiteralSpecRuntimeRoutes`. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/specs/companies.spec.js` | literal | `/api/v1/companies/1/contacts?page=2` | explicit | GET | `internal/handler/api/v1/company_handler_test.go::TestCompanyHandlerSuite/TestChatwootCompanyFrontendLiteralSpecRuntimeRoutes`; route dump | Covered as account-scoped runtime path `/api/v1/accounts/:account_id/companies/:company_id/contacts?page=2`; paginated contact list envelope is covered by `TestCompanyHandlerSuite/TestChatwootCompanyFrontendLiteralSpecRuntimeRoutes`. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/specs/companies.spec.js` | literal | `/api/v1/companies/1/destroy_custom_attributes` | explicit | GET | `internal/handler/api/v1/company_handler_test.go::TestCompanyHandlerSuite/TestChatwootCompanyFrontendLiteralSpecRuntimeRoutes`; route dump | Covered as account-scoped runtime path `/api/v1/accounts/:account_id/companies/:company_id/destroy_custom_attributes`; `{ custom_attributes }` body and mutated payload are covered by `TestCompanyHandlerSuite/TestChatwootCompanyFrontendLiteralSpecRuntimeRoutes`. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/specs/companies.spec.js` | literal | `/api/v1/companies/search?q=&page=1&sort=name` | explicit | GET | `internal/handler/api/v1/company_handler_test.go::TestCompanyHandlerSuite/TestChatwootCompanyFrontendLiteralSpecRuntimeRoutes`; route dump | Covered as account-scoped runtime path `/api/v1/accounts/:account_id/companies/search`; empty query validation behavior is covered by company search fixtures and route dump. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/specs/companies.spec.js` | literal | `/api/v1/companies/search?q=acme+%26+co&page=2&sort=domain` | explicit | GET | `internal/handler/api/v1/company_handler_test.go::TestCompanyHandlerSuite/TestChatwootCompanyFrontendLiteralSpecRuntimeRoutes`; route dump | Covered as account-scoped runtime path `/api/v1/accounts/:account_id/companies/search`; encoded query/page/sort response envelope is covered by `TestCompanyHandlerSuite/TestChatwootCompanyFrontendLiteralSpecRuntimeRoutes`. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/specs/companies.spec.js` | literal | `/api/v1/companies?page=1&sort=name` | explicit | GET | `internal/handler/api/v1/company_handler_test.go::TestCompanyHandlerSuite/TestChatwootCompanyFrontendLiteralSpecRuntimeRoutes`; route dump | Covered as account-scoped runtime path `/api/v1/accounts/:account_id/companies?page=1&sort=name`; list payload/meta and no-trailing-slash route are covered by `TestCompanyHandlerSuite/TestChatwootCompanyFrontendLiteralSpecRuntimeRoutes`. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/specs/contacts.spec.js` | literal | `/api/v1/contacts/1/avatar` | explicit | GET | `internal/handler/api/v1/contact_handler_crud_test.go::TestContactHandlerCRUDTestSuite/TestChatwootFrontendContactsSpecRuntimeRoutes`; route dump | Covered as frontend spec-only bare URL; runtime `contacts.js` is account-scoped under `/api/v1/accounts/:account_id/contacts/:contact_id/avatar`; DELETE clears `avatar_url`/`thumbnail` in Chatwoot payload shape. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/specs/contacts.spec.js` | literal | `/api/v1/contacts/1/contactable_inboxes` | explicit | GET | `internal/handler/api/v1/contact_handler_crud_test.go::TestContactHandlerCRUDTestSuite/TestChatwootFrontendContactsSpecRuntimeRoutes`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped `contactable_inboxes` alias returns `{ payload: [{ inbox, source_id }] }` for Woochat contact forms. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/specs/contacts.spec.js` | literal | `/api/v1/contacts/1/conversations` | explicit | GET | `internal/handler/api/v1/contact_handler_crud_test.go::TestContactHandlerCRUDTestSuite/TestChatwootFrontendContactsSpecRuntimeRoutes`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped contact conversations return Chatwoot conversation payloads including `meta`, `messages`, and account/inbox IDs. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/specs/contacts.spec.js` | literal | `/api/v1/contacts/1/destroy_custom_attributes` | explicit | GET | `internal/handler/api/v1/contact_handler_crud_test.go::TestContactHandlerCRUDTestSuite/TestChatwootFrontendContactsSpecRuntimeRoutes`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped destroy-custom-attributes route accepts `{ custom_attributes }` and returns the updated contact payload. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/specs/contacts.spec.js` | literal | `/api/v1/contacts/1/labels` | explicit | GET | `internal/handler/api/v1/contact_handler_crud_test.go::TestContactHandlerCRUDTestSuite/TestChatwootFrontendContactsSpecRuntimeRoutes`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped labels route covers GET and POST `{ labels }` with raw label-array payloads. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/specs/contacts.spec.js` | literal | `/api/v1/contacts/filter?include_contact_inboxes=false&page=1&sort=name` | explicit | GET | `internal/handler/api/v1/contact_handler_crud_test.go::TestContactHandlerCRUDTestSuite/TestChatwootFrontendContactsSpecRuntimeRoutes`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped filter route accepts Chatwoot filter payloads and honors `include_contact_inboxes=false`, `page`, and `sort` query params. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/specs/contacts.spec.js` | literal | `/api/v1/contacts/import` | explicit | GET | `internal/handler/api/v1/contact_handler_crud_test.go::TestContactHandlerCRUDTestSuite/TestChatwootFrontendContactsSpecRuntimeRoutes`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped import route accepts multipart `import_file`, and missing-file validation returns Chatwoot-compatible 422 `File is blank`. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/specs/contacts.spec.js` | literal | `/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support` | explicit | GET | `internal/handler/api/v1/contact_handler_crud_test.go::TestContactHandlerCRUDTestSuite/TestChatwootFrontendContactsSpecRuntimeRoutes`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped search supports `sort=date`, `labels[]`, `q`, and `include_contact_inboxes=false` with payload/meta response. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/specs/contacts.spec.js` | literal | `/api/v1/contacts?include_contact_inboxes=false&page=1&sort=name&labels[]=customer-support` | explicit | GET | `internal/handler/api/v1/contact_handler_crud_test.go::TestContactHandlerCRUDTestSuite/TestChatwootFrontendContactsSpecRuntimeRoutes`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped list supports label filtering and suppresses embedded contact inboxes when Woochat passes `include_contact_inboxes=false`. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/specs/conversations.spec.js` | literal | `/api/v1/conversations/1/labels` | explicit | - | `internal/handler/api/v1/conversation_handler_crud_test.go::TestConversationCrudTestSuite/TestChatwootFrontendConversationLabelsRuntimeRoutes`; route dump | Covered as frontend spec-only bare URL; runtime `conversations.js` is account-scoped under `/api/v1/accounts/:account_id/conversations/:conversation_id/labels`; GET returns `{ payload: [] }` and POST `{ labels }` returns `{ payload: { conversationId, labels } }` for the Woochat conversationLabels store. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/specs/conversations.spec.js` | literal | `/api/v1/conversations/unread_counts` | explicit | - | `internal/handler/api/v1/conversation_handler_test.go::TestConversationHandlerTestSuite/TestUnreadCounts_Success`; route dump | Covered as frontend spec-only bare URL; runtime `conversations.js` is account-scoped under `/api/v1/accounts/:account_id/conversations/unread_counts` and returns Chatwoot `{ payload: { inboxes, labels, teams } }` without the generic success wrapper. | +| CSAT/Survey | `reference/chatwoot/app/javascript/dashboard/api/specs/csatReports.spec.js` | literal | `/api/v1` | explicit | GET | `internal/handler/api/v1/csat_survey_handler_test.go::TestCSATFrontendContractShapes`; route dump | Covered as frontend spec-only ApiClient version assertion, not a runtime request; `csatReports.js` runtime calls are account-scoped below `/api/v1/accounts/:account_id/csat_survey_responses` and fixture-covered. | +| CSAT/Survey | `reference/chatwoot/app/javascript/dashboard/api/specs/csatReports.spec.js` | literal | `/api/v1/csat_survey_responses` | explicit | GET | `internal/handler/api/v1/csat_survey_handler_test.go::TestCSATFrontendContractShapes`; route dump | Covered as frontend spec-only bare URL; runtime `csatReports.js` is account-scoped under `/api/v1/accounts/:account_id/csat_survey_responses` with `page`, `since`, `until`, `sort`, `user_ids`, `inbox_id`, `team_id`, and `rating` filters plus Chatwoot list item shape. | +| CSAT/Survey | `reference/chatwoot/app/javascript/dashboard/api/specs/csatReports.spec.js` | literal | `/api/v1/csat_survey_responses/download` | explicit | GET | `internal/handler/api/v1/csat_survey_handler_test.go::TestCSATFrontendContractShapes`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped download route accepts the Woochat report filters and returns Chatwoot CSV headers/content-disposition. | +| CSAT/Survey | `reference/chatwoot/app/javascript/dashboard/api/specs/csatReports.spec.js` | literal | `/api/v1/csat_survey_responses/metrics` | explicit | GET | `internal/handler/api/v1/csat_survey_handler_test.go::TestCSATFrontendContractShapes`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped metrics route accepts `since`, `until`, `user_ids`, `inbox_id`, `team_id`, and `rating`, and returns Chatwoot metric keys. | +| Auth/Profile/Account | `reference/chatwoot/app/javascript/dashboard/api/specs/endPoints.spec.js` | literal | `api/v1/accounts.json` | explicit | - | `internal/handler/api/v1/account_handler_test.go::TestAccountHandlerTestSuite/TestCreate_Success`; `internal/handler/api/v1/account_handler_test.go::TestAccountHandlerTestSuite/TestCreate_ChatwootAccountName`; route dump | Covered as frontend spec-only legacy constant from `endPoints.js`; runtime account creation uses `/api/v1/accounts`/account-scoped handlers, with Chatwoot `account_name` alias and response payload covered. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/specs/endPoints.spec.js` | literal | `api/v1/conversations.json` | explicit | - | `internal/handler/api/v1/conversation_handler_crud_test.go::TestConversationCrudTestSuite/TestList_Success`; route dump | Covered as frontend spec-only legacy constant from `endPoints.js`; runtime conversation list is account-scoped under `/api/v1/accounts/:account_id/conversations` and returns Chatwoot list `{ data: { meta, payload } }` shape. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/specs/inbox/conversation.spec.js` | literal | `/api/v1/conversations` | explicit | GET | `internal/handler/api/v1/conversation_handler_crud_test.go::TestConversationCrudTestSuite/TestList_Success`; route dump | Covered as frontend spec-only bare URL; runtime `inbox/conversation.js` is account-scoped under `/api/v1/accounts/:account_id/conversations` and supports Woochat list params with Chatwoot `{ data: { meta, payload } }` response shape. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/specs/inbox/conversation.spec.js` | literal | `/api/v1/conversations/1/attachments` | explicit | GET | `internal/handler/api/v1/conversation_handler_test.go::TestConversationHandlerTestSuite/TestListAttachmentsReturnsChatwootPayload`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped attachments route returns Chatwoot `{ meta: { total_count }, payload }` attachment items. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/specs/inbox/conversation.spec.js` | literal | `/api/v1/conversations/12/assignments` | explicit | GET | `internal/handler/api/v1/conversation_handler_crud_test.go::TestConversationCrudTestSuite/TestAssignTeam_WithChatwootAssigneeID`; `internal/handler/api/v1/conversation_handler_crud_test.go::TestConversationCrudTestSuite/TestAssignTeam_Success`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped assignments route accepts Woochat `assignee_id` and `team_id` payloads and returns serialized user/team objects. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/specs/inbox/conversation.spec.js` | literal | `/api/v1/conversations/12/toggle_status` | explicit | GET | `internal/handler/api/v1/conversation_handler_crud_test.go::TestConversationCrudTestSuite/TestToggleStatus_Success`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped toggle-status route accepts `{ status, snoozed_until }` and returns Chatwoot success/current-status payload. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/specs/inbox/conversation.spec.js` | literal | `/api/v1/conversations/12/toggle_typing_status` | explicit | GET | `internal/handler/api/v1/conversation_handler_test.go::TestConversationHandlerTestSuite/TestToggleTypingStatus_ChatwootRouteSuccess`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped `toggle_typing_status` route accepts `{ typing_status, is_private }` and returns empty 200 like Chatwoot. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/specs/inbox/conversation.spec.js` | literal | `/api/v1/conversations/12/update_last_seen` | explicit | GET | `internal/handler/api/v1/conversation_handler_test.go::TestConversationHandlerTestSuite/TestUpdateLastSeen_SuccessMarksNotificationRead`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped update-last-seen route returns empty 200 and marks matching notifications read. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/specs/inbox/conversation.spec.js` | literal | `/api/v1/conversations/45/custom_attributes` | explicit | GET | `internal/handler/api/v1/conversation_handler_test.go::TestConversationHandlerTestSuite/TestUpdateCustomAttributes_Success`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped custom-attributes route accepts `{ custom_attributes }` and returns raw `{ custom_attributes }` without generic wrapper. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/specs/inbox/conversation.spec.js` | literal | `/api/v1/conversations/45/mute` | explicit | GET | `internal/handler/api/v1/conversation_handler_crud_test.go::TestConversationCrudTestSuite/TestMute_Success`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped mute route returns the serialized conversation with `muted: true`. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/specs/inbox/conversation.spec.js` | literal | `/api/v1/conversations/45/transcript` | explicit | GET | `internal/handler/api/v1/conversation_handler_test.go::TestConversationHandlerTestSuite/TestTranscript_Success`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped transcript route accepts `{ email }` and returns empty 200. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/specs/inbox/conversation.spec.js` | literal | `/api/v1/conversations/45/unmute` | explicit | GET | `internal/handler/api/v1/conversation_handler_crud_test.go::TestConversationCrudTestSuite/TestUnmute_Success`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped unmute route returns the serialized conversation with `muted: false`. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/specs/inbox/conversation.spec.js` | literal | `/api/v1/conversations/filter` | explicit | GET | `internal/handler/api/v1/conversation_handler_crud_test.go::TestConversationCrudTestSuite/TestFilter_Success`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped filter route accepts Woochat filter payload/page params and returns Chatwoot `{ meta, payload }` shape. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/specs/inbox/conversation.spec.js` | literal | `/api/v1/conversations/meta` | explicit | GET | `internal/handler/api/v1/conversation_handler_test.go::TestConversationHandlerTestSuite/TestMeta_Success`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped meta route accepts Woochat finder params and returns `{ meta }` counts. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/specs/inbox/conversation.spec.js` | literal | `/api/v1/conversations/search` | explicit | GET | `internal/handler/api/v1/conversation_handler_crud_test.go::TestConversationCrudTestSuite/TestSearch_Success`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped search route accepts `{ q, page }` query params and returns Chatwoot search `{ meta, payload }` shape. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/specs/inbox/message.spec.js` | literal | `/api/v1/conversations/12/messages` | explicit | - | `internal/handler/api/v1/message_handler_test.go::TestMessageHandlerTestSuite/TestList_Success`; `internal/handler/api/v1/message_handler_test.go::TestMessageHandlerTestSuite/TestCreate_ChatwootFrontendPayloadDefaultsOutgoing`; `internal/handler/api/v1/message_handler_test.go::TestMessageHandlerTestSuite/TestCreate_MultipartAttachmentPersistsAndSerializes`; route dump | Covered as frontend spec-only bare URL; runtime `inbox/message.js` is account-scoped under `/api/v1/accounts/:account_id/conversations/:conversation_id/messages` and covers list, JSON create defaults, multipart attachments, content attributes, echo ID, private flag, sender, and Chatwoot message payload shape. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/specs/inboxes.spec.js` | literal | `/api/v1/inboxes/2/avatar` | explicit | - | `internal/handler/api/v1/inbox_agentbot_avatar_campaigns_test.go::TestInboxHandler_DeleteAvatar_ChatwootHeadOK`; route dump | Covered as frontend spec-only bare URL; runtime `inboxes.js` is account-scoped under `/api/v1/accounts/:account_id/inboxes/:inbox_id/avatar` and returns Chatwoot head-OK while clearing `avatar_url`. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/specs/inboxes.spec.js` | literal | `/api/v1/inboxes/2/campaigns` | explicit | - | `internal/handler/api/v1/inbox_agentbot_avatar_campaigns_test.go::TestInboxHandler_ListCampaigns_ChatwootPayload`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped campaigns route returns inbox-filtered `{ campaigns: [...] }` payload for Woochat inbox campaign panels. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/specs/inboxes.spec.js` | literal | `/api/v1/inboxes/2/sync_templates` | explicit | - | `internal/handler/api/v1/inbox_handler_test.go::TestInboxSyncTemplates_ChatwootQueuedResponse`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped sync-templates route returns Chatwoot queued message response and does not expose provider template internals. | +| Integrations/Channels | `reference/chatwoot/app/javascript/dashboard/api/specs/integrations/dyte.spec.js` | literal | `/api/v1/integrations/dyte/add_participant_to_meeting` | explicit | - | `internal/handler/api/v1/dyte_integration_handler_test.go::TestDyteIntegrationHandlerAddParticipantReturnsAuthToken`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped Dyte add-participant route accepts `message_id/name/email` and returns provider `auth_token` payload without frontend patches. | +| Integrations/Channels | `reference/chatwoot/app/javascript/dashboard/api/specs/integrations/dyte.spec.js` | literal | `/api/v1/integrations/dyte/create_a_meeting` | explicit | - | `internal/handler/api/v1/dyte_integration_handler_test.go::TestDyteIntegrationHandlerCreateMeetingReturnsMessagePayload`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped Dyte create-meeting route creates an integrations message and returns Chatwoot message payload with Dyte meeting attributes. | +| Integrations/Channels | `reference/chatwoot/app/javascript/dashboard/api/specs/integrations/linear.spec.js` | literal | `/api/v1/integrations/linear/create_issue` | explicit | - | `internal/handler/api/v1/linear_integration_handler_test.go::TestLinearIntegration_ChatwootFrontendRuntimeRoutes/create_issue`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped Linear create-issue route accepts Woochat JSON body and returns raw `{ id, title, identifier }` provider shape via fake GraphQL fixture. | +| Integrations/Channels | `reference/chatwoot/app/javascript/dashboard/api/specs/integrations/linear.spec.js` | literal | `/api/v1/integrations/linear/link_issue` | explicit | - | `internal/handler/api/v1/linear_integration_handler_test.go::TestLinearIntegration_ChatwootFrontendRuntimeRoutes/link_issue`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped Linear link-issue route accepts `{ issue_id, conversation_id, title }` and returns raw `{ id, link, link_id }`. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/specs/integrations/linear.spec.js` | literal | `/api/v1/integrations/linear/linked_issues?conversation_id=1` | explicit | - | `internal/handler/api/v1/linear_integration_handler_test.go::TestLinearIntegration_ChatwootFrontendRuntimeRoutes/linked_issues`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped Linear linked-issues route uses `conversation_id` query and returns raw linked attachment array. | +| Integrations/Channels | `reference/chatwoot/app/javascript/dashboard/api/specs/integrations/linear.spec.js` | literal | `/api/v1/integrations/linear/search_issue?q=query` | explicit | - | `internal/handler/api/v1/linear_integration_handler_test.go::TestLinearIntegration_ChatwootFrontendRuntimeRoutes/search_issue`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped Linear search route preserves `q` and returns raw issue array. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/specs/integrations/linear.spec.js` | literal | `/api/v1/integrations/linear/team_entities?team_id=1` | explicit | - | `internal/handler/api/v1/linear_integration_handler_test.go::TestLinearIntegration_ChatwootFrontendRuntimeRoutes/team_entities`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped Linear team-entities route preserves `team_id` and returns `{ users, projects, states, labels }`. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/specs/integrations/linear.spec.js` | literal | `/api/v1/integrations/linear/teams` | explicit | - | `internal/handler/api/v1/linear_integration_handler_test.go::TestLinearIntegration_ChatwootFrontendRuntimeRoutes/teams`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped Linear teams route returns raw team array. | +| Integrations/Channels | `reference/chatwoot/app/javascript/dashboard/api/specs/integrations/linear.spec.js` | literal | `/api/v1/integrations/linear/unlink_issue` | explicit | - | `internal/handler/api/v1/linear_integration_handler_test.go::TestLinearIntegration_ChatwootFrontendRuntimeRoutes/unlink_issue`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped Linear unlink route accepts Woochat `{ link_id, issue_id, conversation_id }` body and returns raw `{ link_id }`. | +| Integrations/Channels | `reference/chatwoot/app/javascript/dashboard/api/specs/integrations.spec.js` | literal | `/api/v1/integrations/2` | explicit | DELETE | `internal/handler/api/v1/integration_hook_handler_suite_test.go::TestChatwootFrontendAppsAndHooksNoTrailingSlashPayloads`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped integrations use hook/app routes and delete without trailing slash returns empty 200 for Woochat store cleanup. | +| Integrations/Channels | `reference/chatwoot/app/javascript/dashboard/api/specs/integrations.spec.js` | literal | `/api/v1/integrations/hooks` | explicit | DELETE | `internal/handler/api/v1/integration_hook_handler_suite_test.go::TestChatwootFrontendAppsAndHooksNoTrailingSlashPayloads`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped create-hook route accepts `{ app_id, settings }` without trailing slash and returns raw hook payload. | +| Integrations/Channels | `reference/chatwoot/app/javascript/dashboard/api/specs/integrations.spec.js` | literal | `/api/v1/integrations/hooks/2` | explicit | DELETE | `internal/handler/api/v1/integration_hook_handler_suite_test.go::TestChatwootFrontendAppsAndHooksNoTrailingSlashPayloads`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped delete-hook route accepts no trailing slash and returns empty 200. | +| Integrations/Channels | `reference/chatwoot/app/javascript/dashboard/api/specs/integrations.spec.js` | literal | `/api/v1/integrations/slack` | explicit | DELETE | `internal/handler/api/v1/slack_integration_handler_test.go::TestSlackIntegration_Create_NoTrailingSlash_ReturnsRawAppPayload`; `internal/handler/api/v1/slack_integration_handler_test.go::TestSlackIntegration_Delete_NoTrailingSlash_ReturnsEmptyOK`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped Slack create/delete routes work without trailing slash, return raw app payload on create, and empty 200 on delete. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/specs/integrations.spec.js` | literal | `/api/v1/integrations/slack/list_all_channels` | explicit | DELETE | `internal/handler/api/v1/slack_integration_handler_test.go::TestSlackIntegration_ListAllChannels_NoTrailingSlash_ReturnsChannelArray`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped Slack list-all-channels route proxies provider channels through a fake HTTP client and returns raw channel array. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/specs/labels.spec.js` | literal | `/api/v1/labels` | explicit | - | `internal/handler/api/v1/label_handler_test.go::TestLabelHandlerSuite/TestListTags_Success`; `internal/handler/api/v1/label_handler_test.go::TestLabelHandlerSuite/TestCreateTag_RawNameCompatibility`; `internal/handler/api/v1/label_handler_test.go::TestLabelHandlerSuite/TestUpdateTag_ChatwootPayloadAndAccountScope`; `internal/handler/api/v1/label_handler_test.go::TestLabelHandlerSuite/TestDeleteTag_ReturnsOKAndRemovesAssociations`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped labels route supports list/create/update/delete and returns Chatwoot raw tag payloads without generic success wrapper. | +| Automation/Enterprise | `reference/chatwoot/app/javascript/dashboard/api/specs/macros.spec.js` | literal | `/api/v1/macros` | explicit | - | `internal/handler/api/v1/macro_handler_test.go::TestMacroHandlerSuite/TestList_Empty`; `internal/handler/api/v1/macro_handler_test.go::TestMacroHandlerSuite/TestCreate_Success`; `internal/handler/api/v1/macro_handler_test.go::TestMacroHandlerSuite/TestUpdateDeleteAuthorizationAndPayload`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped macros route supports list/create/update/delete and Chatwoot macro payload fields/actions used by Woochat. | +| Other | `reference/chatwoot/app/javascript/dashboard/api/specs/notifications.spec.js` | literal | `/api/v1/notifications` | explicit | GET, DELETE | `internal/handler/api/v1/notification_handler_test.go::TestNotificationHandler_List_ChatwootEnvelopeAndIncludes`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped notification list accepts Chatwoot include/sort params and returns `{ meta, payload }`. | +| Other | `reference/chatwoot/app/javascript/dashboard/api/specs/notifications.spec.js` | literal | `/api/v1/notifications/1` | explicit | GET, DELETE | `internal/handler/api/v1/notification_handler_test.go::TestNotificationGet`; `internal/handler/api/v1/notification_handler_test.go::TestNotificationSoftDelete`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped show/delete routes return serialized notifications and soft-delete account-scoped records. | +| Other | `reference/chatwoot/app/javascript/dashboard/api/specs/notifications.spec.js` | literal | `/api/v1/notifications/1/notifications` | explicit | GET, DELETE | `internal/handler/api/v1/notification_handler_test.go::TestNotificationHandler_List_ChatwootEnvelopeAndIncludes`; route dump | Covered as frontend spec-only literal from the JS helper; runtime Woochat flow uses account-scoped notification collection and actor serialization fixtures. | +| Other | `reference/chatwoot/app/javascript/dashboard/api/specs/notifications.spec.js` | literal | `/api/v1/notifications/1/snooze` | explicit | GET, DELETE | `internal/handler/api/v1/notification_handler_test.go::TestNotificationHandler_SnoozeWithDB`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped snooze route accepts `{ snoozed_until }` and returns the updated Chatwoot notification payload. | +| Other | `reference/chatwoot/app/javascript/dashboard/api/specs/notifications.spec.js` | literal | `/api/v1/notifications/destroy_all` | explicit | GET, DELETE | `internal/handler/api/v1/notification_handler_test.go::TestNotificationHandler_DestroyAllWithDB`; `internal/handler/api/v1/notification_handler_test.go::TestNotificationHandler_DestroyAll_ReadOnly`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped destroy-all route accepts Woochat body filters and returns empty OK while preserving read-only filter semantics. | +| Other | `reference/chatwoot/app/javascript/dashboard/api/specs/notifications.spec.js` | literal | `/api/v1/notifications/read_all` | explicit | GET, DELETE | `internal/handler/api/v1/notification_handler_test.go::TestNotificationReadAllWithDB`; `internal/handler/api/v1/notification_handler_test.go::TestNotificationHandler_MarkAllRead_PrimaryActorOnly`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped read-all route supports full read and primary actor filters used by Woochat. | +| Other | `reference/chatwoot/app/javascript/dashboard/api/specs/notifications.spec.js` | literal | `/api/v1/notifications/unread_count` | explicit | GET, DELETE | `internal/handler/api/v1/notification_handler_test.go::TestNotificationHandler_UnreadWithDB`; `internal/handler/api/v1/notification_handler_test.go::TestNotificationUnreadCountIsAccountScoped`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped unread-count route returns `{ count }` and enforces account scope. | +| Reports/Audit | `reference/chatwoot/app/javascript/dashboard/api/specs/reports.spec.js` | literal | `/api/v2` | explicit | - | `internal/handler/api/v1/analytics_handler_test.go::TestAnalyticsHandlerTestSuite/TestAPIV2Reports_ChatwootPayloadShapes`; route dump | Covered as frontend spec-only ApiClient version assertion; runtime reports are account-scoped under `/api/v2/accounts/:account_id/reports...`. | +| Reports/Audit | `reference/chatwoot/app/javascript/dashboard/api/specs/reports.spec.js` | literal | `/api/v2/reports` | explicit | - | `internal/handler/api/v1/analytics_handler_test.go::TestAnalyticsHandlerTestSuite/TestAPIV2Reports_ChatwootPayloadShapes`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped APIV2 reports route accepts `metric/since/until/type/group_by/timezone_offset` and returns Chatwoot timeseries points. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/specs/reports.spec.js` | literal | `/api/v2/reports/agents` | explicit | - | `internal/handler/api/v1/analytics_handler_test.go::TestAnalyticsHandlerTestSuite/TestAPIV2Reports_ChatwootPayloadShapes`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped agent reports route returns Chatwoot CSV headers/shape. | +| Reports/Audit | `reference/chatwoot/app/javascript/dashboard/api/specs/reports.spec.js` | literal | `/api/v2/reports/bot_metrics` | explicit | - | `internal/handler/api/v1/analytics_handler_test.go::TestAnalyticsHandlerTestSuite/TestAPIV2Reports_AllFrontendEndpointShapes`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped bot-metrics route returns Chatwoot JSON metric keys. | +| Reports/Audit | `reference/chatwoot/app/javascript/dashboard/api/specs/reports.spec.js` | literal | `/api/v2/reports/bot_summary` | explicit | - | `internal/handler/api/v1/analytics_handler_test.go::TestAnalyticsHandlerTestSuite/TestAPIV2Reports_AllFrontendEndpointShapes`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped bot-summary route accepts `group_by/business_hours` and returns Chatwoot summary keys. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/specs/reports.spec.js` | literal | `/api/v2/reports/conversations` | explicit | - | `internal/handler/api/v1/analytics_handler_test.go::TestAnalyticsHandlerTestSuite/TestAPIV2Reports_AllFrontendEndpointShapes`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped conversation reports route returns Chatwoot conversation report rows. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/specs/reports.spec.js` | literal | `/api/v2/reports/inboxes` | explicit | - | `internal/handler/api/v1/analytics_handler_test.go::TestAnalyticsHandlerTestSuite/TestAPIV2Reports_AllFrontendEndpointShapes`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped inbox reports route returns Chatwoot CSV headers/shape. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/specs/reports.spec.js` | literal | `/api/v2/reports/labels` | explicit | - | `internal/handler/api/v1/analytics_handler_test.go::TestAnalyticsHandlerTestSuite/TestAPIV2Reports_AllFrontendEndpointShapes`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped label reports route returns Chatwoot CSV headers/shape. | +| Reports/Audit | `reference/chatwoot/app/javascript/dashboard/api/specs/reports.spec.js` | literal | `/api/v2/reports/summary` | explicit | - | `internal/handler/api/v1/analytics_handler_test.go::TestAnalyticsHandlerTestSuite/TestAPIV2Reports_ChatwootPayloadShapes`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped summary route returns Chatwoot summary keys and counts. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/specs/reports.spec.js` | literal | `/api/v2/reports/teams` | explicit | - | `internal/handler/api/v1/analytics_handler_test.go::TestAnalyticsHandlerTestSuite/TestAPIV2Reports_AllFrontendEndpointShapes`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped team reports route returns Chatwoot CSV headers/shape. | +| Other | `reference/chatwoot/app/javascript/dashboard/api/specs/search.spec.js` | literal | `/api/v1/search` | explicit | GET | `internal/handler/api/v1/search_handler_test.go::TestSearchHandler_GlobalSearch_UsesReferenceResultTypes`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped global search preserves `q` and returns Chatwoot reference result types. | +| Help Center | `reference/chatwoot/app/javascript/dashboard/api/specs/search.spec.js` | literal | `/api/v1/search/articles` | explicit | GET | `internal/handler/api/v1/search_handler_test.go::TestSearchHandler_SearchArticles_HydratesPortalAndCategoryPayload`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped article search preserves filters and hydrates Chatwoot portal/category article payload. | +| CRM | `reference/chatwoot/app/javascript/dashboard/api/specs/search.spec.js` | literal | `/api/v1/search/contacts` | explicit | GET | `internal/handler/api/v1/search_handler_test.go::TestSearchHandler_SearchContacts_Success`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped contact search preserves `q/page/since/until` style params and returns Chatwoot `{ payload: { contacts } }`. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/specs/search.spec.js` | literal | `/api/v1/search/conversations` | explicit | GET | `internal/handler/api/v1/search_handler_test.go::TestSearchHandler_SearchConversations_ChatwootPayloadShape`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped conversation search returns Chatwoot conversation payload shape and ignores unsupported reference filters safely. | +| Conversation/Message | `reference/chatwoot/app/javascript/dashboard/api/specs/search.spec.js` | literal | `/api/v1/search/messages` | explicit | GET | `internal/handler/api/v1/search_handler_test.go::TestSearchHandler_SearchMessages_HydratesChatwootMessagePayload`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped message search preserves supported filters and hydrates Chatwoot message payload. | +| Reports/Audit | `reference/chatwoot/app/javascript/dashboard/api/specs/slaReports.spec.js` | literal | `/api/v1` | explicit | GET | `internal/handler/api/v1/sla_policy_handler_test.go::TestSlaPolicyHandler_ListAppliedSlas_ChatwootPayloadAndFilters`; route dump | Covered as frontend spec-only ApiClient version assertion; runtime applied-SLA report routes are account-scoped under `/api/v1/accounts/:account_id/applied_slas`. | +| Reports/Audit | `reference/chatwoot/app/javascript/dashboard/api/specs/slaReports.spec.js` | literal | `/api/v1/applied_slas` | explicit | GET | `internal/handler/api/v1/sla_policy_handler_test.go::TestSlaPolicyHandler_ListAppliedSlas_ChatwootPayloadAndFilters`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped applied-SLA list preserves frontend filters and returns Chatwoot `{ meta, payload }` shape. | +| Reports/Audit | `reference/chatwoot/app/javascript/dashboard/api/specs/slaReports.spec.js` | literal | `/api/v1/applied_slas/download` | explicit | GET | `internal/handler/api/v1/sla_policy_handler_test.go::TestSlaPolicyHandler_GetAppliedSlaDownload_Success`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped applied-SLA download returns Chatwoot CSV headers/body. | +| Reports/Audit | `reference/chatwoot/app/javascript/dashboard/api/specs/slaReports.spec.js` | literal | `/api/v1/applied_slas/metrics` | explicit | GET | `internal/handler/api/v1/sla_policy_handler_test.go::TestSlaPolicyHandler_GetAppliedSlaMetrics_ChatwootReportShape`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped applied-SLA metrics returns Chatwoot report totals by SLA status. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/specs/teams.spec.js` | literal | `/api/v1/teams/1/team_members` | explicit | - | `internal/handler/api/v1/team_handler_test.go::TestTeamHandlerSuite/TestTeamMembers_ChatwootPayloadAndDiffUpdate`; route dump | Covered as frontend spec-only bare URL; runtime account-scoped team-members route supports GET/PATCH diff updates and returns Chatwoot agent serializer array. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/specs/tiktokClient.spec.js` | literal | `/api/v1/accounts/1/tiktok/authorization` | explicit | - | `internal/handler/api/v1/tiktok_channel_handler_test.go::TestTikTokChannel_Create_ChatwootSetupPayloadAndConfig`; route parity exact for TikTok authorization | Covered for TikTok authorization/setup endpoint path and payload used by Woochat setup. | +| Reports/Audit | `reference/chatwoot/app/javascript/dashboard/api/summaryReports.js` | ApiClient | `summary_reports` | account-scoped | GET | `internal/handler/api/v1/summary_report_handler_test.go::TestSummaryReportHandlerTestSuite/TestAgent_Success`; route dump | Covered as account-scoped runtime path; summary reports validate date range and return Chatwoot array payloads for Woochat summary report clients. | +| Inbox/Agents/Assignment | `reference/chatwoot/app/javascript/dashboard/api/teams.js` | ApiClient | `teams` | account-scoped | GET, POST, PATCH | `internal/handler/api/v1/team_handler_test.go::TestTeamHandlerSuite/TestList_Success`; `internal/handler/api/v1/team_handler_test.go::TestTeamHandlerSuite/TestCreate_Success`; `internal/handler/api/v1/team_handler_test.go::TestTeamHandlerSuite/TestUpdate_PatchRawPayloadSuccess`; route dump | Covered as account-scoped runtime path; teams list/create/patch return raw Chatwoot team payloads including `allow_auto_assign` and `is_member`. | +| Other | `reference/chatwoot/app/javascript/dashboard/api/userNotificationSettings.js` | ApiClient | `notification_settings` | account-scoped | PATCH | `internal/handler/api/v1/notification_setting_handler_test.go::TestNotificationSettingHandlerSuite/TestUpdate_ReturnsRawChatwootPayload`; route dump | Covered as account-scoped runtime path; notification settings PATCH returns raw Chatwoot preference payload without generic wrapper. | +| Other | `reference/chatwoot/app/javascript/dashboard/api/webhooks.js` | ApiClient | `webhooks` | account-scoped | standard CRUD | `internal/handler/api/v1/webhook_subscription_handler_test.go::TestWebhookSubscriptionHandlerSuite/TestList_Success`; `internal/handler/api/v1/webhook_subscription_handler_test.go::TestWebhookSubscriptionHandlerSuite/TestCreate_Success_ChatwootPayload`; `internal/handler/api/v1/webhook_subscription_handler_test.go::TestWebhookSubscriptionHandlerSuite/TestUpdate_Success_ChatwootPayload`; `internal/handler/api/v1/webhook_subscription_handler_test.go::TestWebhookSubscriptionHandlerSuite/TestDelete_Success_ReturnsEmptyOK`; route dump | Covered as account-scoped runtime path; webhooks CRUD returns Chatwoot `{ payload: { webhook(s) } }` shape and empty OK delete. | +| Other | `reference/chatwoot/app/javascript/dashboard/api/yearInReview.js` | ApiClient | `year_in_review` | account-scoped | GET | `internal/handler/api/v1/year_in_review_handler_test.go::TestYearInReviewShowReturnsRawChatwootPayload`; route dump | Covered as account-scoped APIV2 runtime path; year-in-review returns raw Chatwoot `{ year, total_conversations, busiest_day, support_personality }` payload. | +| CSAT/Survey | `reference/chatwoot/app/javascript/survey/api/endPoints.js` | literal | `/public/api/v1/csat_survey/${uuid}` | explicit | - | `internal/handler/api/v1/csat_survey_handler_test.go::TestCsatSurveyHandlerTestSuite/TestPublicCsatShowAndUpdate_Success`; `internal/handler/api/v1/csat_survey_handler_test.go::TestCsatSurveyHandlerTestSuite/TestPublicCsatUpdate_ChatwootPutPersistsShowPayload`; route dump | Covered as public runtime path; CSAT survey show/update works by UUID and returns persisted Chatwoot public survey payload. | +| CSAT/Survey | `reference/chatwoot/app/javascript/survey/api/specs/endPoints.spec.js` | literal | `/public/api/v1/csat_survey/98c5d7f3-8873-4262-b101-d56425ff7ee1` | explicit | - | `internal/handler/api/v1/csat_survey_handler_test.go::TestCsatSurveyHandlerTestSuite/TestPublicCsatShowAndUpdate_Success`; `internal/handler/api/v1/csat_survey_handler_test.go::TestCsatSurveyHandlerTestSuite/TestPublicCsatUpdate_ChatwootPutPersistsShowPayload`; route dump | Covered as frontend spec-only fixed UUID literal; runtime public CSAT UUID route returns and updates Chatwoot-compatible survey response payload. | +| Widget | `reference/chatwoot/app/javascript/widget/api/contacts.js` | literal | `/api/v1/${endPoint}${window.location.search}` | explicit | GET, POST, PATCH | `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootConfigCwConversationReusesContactInbox`; `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootMessageUpdate_SubmitsEmail`; `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootDestroyContactCustomAttributes`; route dump | Covered for widget contact literal builder; runtime `widget/contact`, `set_user`, and contact custom-attribute actions use query/cookie auth and return Chatwoot widget contact payloads. | +| Widget | `reference/chatwoot/app/javascript/widget/api/conversation.js` | literal | `/api/v1/widget/conversations${window.location.search}` | explicit | GET, POST | `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootConversationQueryTokenReusesSession`; `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_GetConversations_Success`; route dump | Covered for widget conversation GET/POST; runtime preserves `website_token`/`cw_conversation` session reuse and returns Chatwoot conversation payloads. | +| Widget | `reference/chatwoot/app/javascript/widget/api/conversation.js` | literal | `/api/v1/widget/conversations/destroy_custom_attributes${window.location.search}` | explicit | GET, POST | `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootConversationCustomAttributeResponses`; route dump | Covered for widget conversation custom-attribute delete; runtime accepts Chatwoot body and returns updated conversation payload. | +| Widget | `reference/chatwoot/app/javascript/widget/api/conversation.js` | literal | `/api/v1/widget/conversations/set_custom_attributes${window.location.search}` | explicit | GET, POST | `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootConversationCustomAttributeResponses`; route dump | Covered for widget conversation custom-attribute set; runtime accepts `{ custom_attributes }` and returns empty OK like Chatwoot. | +| Widget | `reference/chatwoot/app/javascript/widget/api/conversation.js` | literal | `/api/v1/widget/conversations/toggle_status${window.location.search}` | explicit | GET, POST | `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootConversationHeadActionsReturnEmptyOK`; `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootToggleStatusHonorsEndConversationFlag`; route dump | Covered for widget toggle-status; runtime returns empty OK when enabled and stable forbidden/not-found states when unavailable. | +| Widget | `reference/chatwoot/app/javascript/widget/api/conversation.js` | literal | `/api/v1/widget/conversations/toggle_typing${window.location.search}` | explicit | GET, POST | `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootConversationHeadActionsReturnEmptyOK`; `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ToggleTyping_Success`; route dump | Covered for widget toggle-typing; runtime accepts `{ typing_status }` and returns empty OK while publishing widget typing payload. | +| Widget | `reference/chatwoot/app/javascript/widget/api/conversation.js` | literal | `/api/v1/widget/conversations/transcript${window.location.search}` | explicit | GET, POST | `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootSetUserAndTranscript`; `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootTranscriptStatusParity`; route dump | Covered for widget transcript; runtime returns Chatwoot accepted/empty status shapes based on contact email and conversation context. | +| Widget | `reference/chatwoot/app/javascript/widget/api/conversation.js` | literal | `/api/v1/widget/conversations/update_last_seen${window.location.search}` | explicit | GET, POST | `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootConversationHeadActionsReturnEmptyOK`; route dump | Covered for widget update-last-seen; runtime accepts contact last-seen body and returns empty OK. | +| Widget | `reference/chatwoot/app/javascript/widget/api/conversationLabels.js` | literal | `/api/v1/${endPoint}${window.location.search}` | explicit | POST, DELETE | `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootEventsAndLabels`; route dump | Covered for widget label builder; runtime supports POST `widget/labels` and DELETE `widget/labels/:label` using auth token or `cw_conversation` cookie. | +| Widget | `reference/chatwoot/app/javascript/widget/api/endPoints.js` | literal | `/api/v1/widget/campaigns` | explicit | - | `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootCampaigns_Success`; route dump | Covered for widget campaigns; runtime filters enabled campaigns by website token and returns raw Chatwoot campaign array. | +| Widget | `reference/chatwoot/app/javascript/widget/api/endPoints.js` | literal | `/api/v1/widget/conversations${search}` | explicit | - | `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootConversationQueryTokenReusesSession`; route dump | Covered for endpoint builder create-conversation path; runtime creates/reuses widget conversation from query params. | +| Widget | `reference/chatwoot/app/javascript/widget/api/endPoints.js` | literal | `/api/v1/widget/events` | explicit | - | `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootEventsAndLabels`; route dump | Covered for widget events; runtime accepts event body via auth token or cookie and returns no-content. | +| Widget | `reference/chatwoot/app/javascript/widget/api/endPoints.js` | literal | `/api/v1/widget/inbox_members` | explicit | - | `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootInboxMembers_Success`; route dump | Covered for widget inbox members; runtime returns `{ payload: [...] }` with Chatwoot agent availability fields. | +| Widget | `reference/chatwoot/app/javascript/widget/api/endPoints.js` | literal | `/api/v1/widget/messages${search}` | explicit | - | `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootMessageAppliesAttrsAndLabelsToNewConversation`; `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootMessagesIndexFiltersInternalMessages`; route dump | Covered for widget message create/list endpoints; runtime applies custom attributes/labels on first conversation and returns Chatwoot message list payloads. | +| Widget | `reference/chatwoot/app/javascript/widget/api/endPoints.js` | literal | `/api/v1/widget/messages${window.location.search}` | explicit | - | `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootMessages_AuthTokenAndNestedPayload`; route dump | Covered for widget messages with `window.location.search`; runtime accepts nested Chatwoot message body and auth token. | +| Widget | `reference/chatwoot/app/javascript/widget/api/endPoints.js` | literal | `/api/v1/widget/messages/${id}${window.location.search}` | explicit | - | `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootMessageUpdate_SubmitsEmail`; route dump | Covered for widget message update; runtime supports Chatwoot email-submit update path with auth token. | +| Widget | `reference/chatwoot/app/javascript/widget/api/endPoints.js` | literal | `/hc/${slug}/${locale}/articles.json` | explicit | - | `internal/handler/api/v1/article_handler_test.go::TestArticleHandlerSuite/TestPublicList_WidgetPopularArticles`; route dump | Covered for widget help-center article JSON path; runtime public help-center route returns published locale article payloads. | +| Widget | `reference/chatwoot/app/javascript/widget/api/events.js` | literal | `/api/v1/widget/events${search}` | explicit | POST | `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootEventsAndLabels`; route dump | Covered for widget events search builder; runtime accepts `website_token` query or cookie session and returns no-content. | +| Widget | `reference/chatwoot/app/javascript/widget/api/integration.js` | literal | `/api/v1/widget/integrations/dyte/add_participant_to_meeting${search}` | explicit | POST | `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootDyteParticipant`; route dump | Covered for widget Dyte integration; runtime accepts message/participant body and returns provider auth token payload. | +| Widget | `reference/chatwoot/app/javascript/widget/api/specs/endPoints.spec.js` | literal | `/api/v1/widget/events` | explicit | - | `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootEventsAndLabels`; route dump | Covered as frontend spec fixed widget-events literal; runtime widget events route returns no-content. | +| Widget | `reference/chatwoot/app/javascript/widget/api/specs/endPoints.spec.js` | literal | `/api/v1/widget/messages` | explicit | - | `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootMessages_AuthTokenAndNestedPayload`; route dump | Covered as frontend spec fixed widget-messages literal; runtime accepts Chatwoot widget message body and returns message payload. | +| Widget | `reference/chatwoot/app/javascript/widget/api/specs/endPoints.spec.js` | literal | `/api/v1/widget/messages?param=1&locale=ar` | explicit | - | `internal/handler/widget/widget_handler_test.go::TestWidgetHandler_ChatwootMessages_AuthTokenAndNestedPayload`; route dump | Covered as frontend spec fixed widget-messages query literal; runtime preserves query params while authenticating via widget token/auth token and returning message payload. | + +## Required Follow-Up + +- Convert the highest-traffic rows into fixture contract tests: auth/profile, accounts/cache keys, inboxes, conversations/messages, contacts/companies, widget messages, CSAT, reports, and enterprise settings. +- Attach live `scripts/parity_frontend_smoke.sh --api-smoke` and `--browser-smoke` evidence to the rows that are exercised by smoke flows. +- Keep any new frontend API client file from `reference/chatwoot` mapped here before claiming direct-connect compatibility. diff --git a/docs/parity/frontend_smoke_report.md b/docs/parity/frontend_smoke_report.md index dc26b25f..caef11b6 100644 --- a/docs/parity/frontend_smoke_report.md +++ b/docs/parity/frontend_smoke_report.md @@ -1,10 +1,10 @@ # Frontend Smoke Report -Updated: 2026-06-06T17:52:53Z +Updated: 2026-06-13T12:24:46Z ## Status -Harness readiness check passed; live frontend smoke not run in this mode. +Enterprise browser smoke passed for reused Chatwoot enterprise route requests and widget boot; enterprise API smoke passed. ## Boot Command @@ -38,17 +38,17 @@ scripts/parity_frontend_smoke.sh --enterprise-browser-smoke ## Backend -- URL: http://127.0.0.1:3000 -- Command: `env GOCHAT_ENV=development GOCHAT_SERVER_HOST=127.0.0.1 GOCHAT_SERVER_PORT=3000 GOCHAT_SERVER_MODE=debug GOCHAT_SEARCH_ENGINE=meilisearch GOCHAT_SEARCH_HOST=http://127.0.0.1:7700 GOCHAT_SEARCH_API_KEY=gochat_dev GOCHAT_CAPTAIN_ENABLED=false go run ./cmd/gochat serve` -- Search: `meilisearch` at `http://127.0.0.1:7700` -- Log: `/home/rogee/Projects/gochat/.tmp/frontend-smoke/gochat.log` +- URL: http://127.0.0.1:13000 +- Command: `env GOCHAT_ENV=development GOCHAT_SERVER_HOST=127.0.0.1 GOCHAT_SERVER_PORT=13000 GOCHAT_SERVER_MODE=debug GOCHAT_SEARCH_ENGINE=meilisearch GOCHAT_SEARCH_HOST=http://127.0.0.1:17700 GOCHAT_SEARCH_API_KEY=gochat_dev GOCHAT_CAPTAIN_ENABLED=false go run ./cmd/gochat serve` +- Search: `meilisearch` at `http://127.0.0.1:17700` +- Log: `/home/rogee/Projects/gochat/.tmp/frontend-smoke-live/gochat.log` ## Reused Chatwoot Frontend -- URL: http://127.0.0.1:3036 +- URL: http://localhost:3036 - Source: `/home/rogee/Projects/gochat/reference/chatwoot` -- Command: `(cd /home/rogee/Projects/gochat/reference/chatwoot && env CHATWOOT_API_HOST=http://127.0.0.1:3000 pnpm exec vite --host 127.0.0.1 --port 3036)` -- Log: `/home/rogee/Projects/gochat/.tmp/frontend-smoke/chatwoot-vite.log` +- Command: `(cd /home/rogee/Projects/gochat/reference/chatwoot && env CHATWOOT_API_HOST=http://127.0.0.1:13000 pnpm exec vite --host localhost --port 3036)` +- Log: `/home/rogee/Projects/gochat/.tmp/frontend-smoke-live/chatwoot-vite.log` ## Seed Path @@ -66,19 +66,20 @@ The seed command creates deterministic login/account/inbox/contact/company/conve | Area | Current result | Owner if failing | | --- | --- | --- | -| Boot GoChat backend | Not run in check mode | B12.1 | -| Boot reused Chatwoot Vite frontend | Not run in check mode | B12.1 | -| Auth/profile | Pending browser/API smoke | B2/B12.2 | -| Inbox list/settings | Pending browser/API smoke | B5/B12.2 | -| Conversation list/detail/message send | Pending browser/API smoke | B3/B12.2 | -| Contact/company views | Pending browser/API smoke | B4/B12.2 | -| Widget config/message | Pending browser/API smoke | B12.2 | -| Public CSAT | Pending browser/API smoke | B8/B12.2 | -| Enterprise screens | Pending browser/API smoke | B7-B11/B12.3 | +| Boot GoChat backend | Passed /health during enterprise browser smoke | B12.1 | +| Boot reused Chatwoot Vite frontend | Passed Vite readiness during enterprise browser smoke | B12.1 | +| Auth/profile | Passed browser login plus API smoke | B2/B12.2 | +| Inbox list/settings | Passed API smoke | B5/B12.2 | +| Conversation list/detail/message send | Passed dashboard browser request plus API smoke | B3/B12.2 | +| Contact/company views | Passed contact/company API smoke | B4/B12.2 | +| Search/indexing | Passed search API smoke | B6/B12.2 | +| Widget config/message | Passed API smoke | B12.2 | +| Public CSAT | Passed CSAT API/download and browser route requests | B8/B12.2 | +| Enterprise screens | Passed enterprise browser route requests for SLA, CSAT, automation, macros, audit, custom roles, capacity, Captain, and Copilot | B7-B11/B12.3 | ## Notes -Run `scripts/parity_frontend_smoke.sh --boot-only` after PostgreSQL, Redis, Meilisearch, and Chatwoot frontend dependencies are available. +Command run: `scripts/parity_frontend_smoke.sh --enterprise-browser-smoke`. Browser report: `/home/rogee/Projects/gochat/.tmp/frontend-smoke-live/browser-smoke-report.json`. When using the default Meilisearch-first boot command, start Meilisearch separately, for example: diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index cfb46697..97f656d2 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -95,6 +95,7 @@ DELETE /api/v1/accounts/:account_id/portals/:portal_id/categories/:category_id DELETE /api/v1/accounts/:account_id/portals/:portal_id/folders/:folder_id DELETE /api/v1/accounts/:account_id/portals/:portal_id/logo DELETE /api/v1/accounts/:account_id/portals/:portal_id/members/:member_id +DELETE /api/v1/accounts/:account_id/saml_settings DELETE /api/v1/accounts/:account_id/saml_settings/:id DELETE /api/v1/accounts/:account_id/sla_policies/:id DELETE /api/v1/accounts/:account_id/sla_policies/:id/inboxes/:inbox_id @@ -140,6 +141,7 @@ GET /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/bot_rules/ GET /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/bot_rules/:rule_id GET /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/trigger_configs/ GET /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/trigger_configs/:trigger_config_id +GET /api/v1/accounts/:account_id/agent_capacity_policies GET /api/v1/accounts/:account_id/agent_capacity_policies/ GET /api/v1/accounts/:account_id/agent_capacity_policies/:id GET /api/v1/accounts/:account_id/agent_capacity_policies/:id/users @@ -157,8 +159,10 @@ GET /api/v1/accounts/:account_id/assignment_policies_v2 GET /api/v1/accounts/:account_id/assignment_policies_v2/:id GET /api/v1/accounts/:account_id/assignment_policies_v2/:id/inboxes GET /api/v1/accounts/:account_id/assignment_policy +GET /api/v1/accounts/:account_id/audit_logs GET /api/v1/accounts/:account_id/audit_logs/ GET /api/v1/accounts/:account_id/audit_logs/:id +GET /api/v1/accounts/:account_id/automation_rules GET /api/v1/accounts/:account_id/automation_rules/ GET /api/v1/accounts/:account_id/automation_rules/:automation_id GET /api/v1/accounts/:account_id/banners @@ -173,6 +177,7 @@ GET /api/v1/accounts/:account_id/canned_responses/:id GET /api/v1/accounts/:account_id/canned_responses/search GET /api/v1/accounts/:account_id/captain/assistant_responses/ GET /api/v1/accounts/:account_id/captain/assistant_responses/:response_id +GET /api/v1/accounts/:account_id/captain/assistants GET /api/v1/accounts/:account_id/captain/assistants/ GET /api/v1/accounts/:account_id/captain/assistants/:assistant_id GET /api/v1/accounts/:account_id/captain/assistants/:assistant_id/documents/ @@ -183,8 +188,10 @@ GET /api/v1/accounts/:account_id/captain/assistants/:assistant_id/scenarios/:sce GET /api/v1/accounts/:account_id/captain/assistants/tools GET /api/v1/accounts/:account_id/captain/copilot/stream GET /api/v1/accounts/:account_id/captain/copilot_messages/ +GET /api/v1/accounts/:account_id/captain/copilot_threads GET /api/v1/accounts/:account_id/captain/copilot_threads/ GET /api/v1/accounts/:account_id/captain/copilot_threads/:thread_id +GET /api/v1/accounts/:account_id/captain/copilot_threads/:thread_id/copilot_messages GET /api/v1/accounts/:account_id/captain/copilot_threads/:thread_id/copilot_messages/ GET /api/v1/accounts/:account_id/captain/custom_tools/ GET /api/v1/accounts/:account_id/captain/custom_tools/:tool_id @@ -198,6 +205,7 @@ GET /api/v1/accounts/:account_id/captain/tasks/follow_up GET /api/v1/accounts/:account_id/captain/tasks/label_suggestion GET /api/v1/accounts/:account_id/channels/facebook_channel/:fb_id GET /api/v1/accounts/:account_id/channels/facebook_channel/authorization +GET /api/v1/accounts/:account_id/companies GET /api/v1/accounts/:account_id/companies/ GET /api/v1/accounts/:account_id/companies/:company_id GET /api/v1/accounts/:account_id/companies/:company_id/contacts @@ -206,6 +214,7 @@ GET /api/v1/accounts/:account_id/companies/:company_id/conversations GET /api/v1/accounts/:account_id/companies/:company_id/notes GET /api/v1/accounts/:account_id/companies/search GET /api/v1/accounts/:account_id/contact_inboxes/filter +GET /api/v1/accounts/:account_id/contacts GET /api/v1/accounts/:account_id/contacts/ GET /api/v1/accounts/:account_id/contacts/:contact_id GET /api/v1/accounts/:account_id/contacts/:contact_id/attachments @@ -220,6 +229,7 @@ GET /api/v1/accounts/:account_id/contacts/active GET /api/v1/accounts/:account_id/contacts/export GET /api/v1/accounts/:account_id/contacts/export/:export_id/download GET /api/v1/accounts/:account_id/contacts/search +GET /api/v1/accounts/:account_id/conversations GET /api/v1/accounts/:account_id/conversations/ GET /api/v1/accounts/:account_id/conversations/:conversation_id GET /api/v1/accounts/:account_id/conversations/:conversation_id/attachments @@ -228,6 +238,7 @@ GET /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages/ GET /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages/:draft_id GET /api/v1/accounts/:account_id/conversations/:conversation_id/inbox_assistant GET /api/v1/accounts/:account_id/conversations/:conversation_id/labels +GET /api/v1/accounts/:account_id/conversations/:conversation_id/messages GET /api/v1/accounts/:account_id/conversations/:conversation_id/messages/ GET /api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id GET /api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id/delivery_status/ @@ -239,6 +250,7 @@ GET /api/v1/accounts/:account_id/conversations/:conversation_id/whatsapp_calls/: GET /api/v1/accounts/:account_id/conversations/meta GET /api/v1/accounts/:account_id/conversations/search GET /api/v1/accounts/:account_id/conversations/unread_counts +GET /api/v1/accounts/:account_id/csat_survey_responses GET /api/v1/accounts/:account_id/csat_survey_responses/ GET /api/v1/accounts/:account_id/csat_survey_responses/download GET /api/v1/accounts/:account_id/csat_survey_responses/metrics @@ -246,6 +258,7 @@ GET /api/v1/accounts/:account_id/custom_attribute_definitions/ GET /api/v1/accounts/:account_id/custom_attribute_definitions/:id GET /api/v1/accounts/:account_id/custom_filters/ GET /api/v1/accounts/:account_id/custom_filters/:id +GET /api/v1/accounts/:account_id/custom_roles GET /api/v1/accounts/:account_id/custom_roles/ GET /api/v1/accounts/:account_id/custom_roles/:id GET /api/v1/accounts/:account_id/dashboard_apps @@ -263,6 +276,7 @@ GET /api/v1/accounts/:account_id/google_channels/authorization GET /api/v1/accounts/:account_id/hooks/ GET /api/v1/accounts/:account_id/hooks/:id GET /api/v1/accounts/:account_id/inbox_members/:inbox_id +GET /api/v1/accounts/:account_id/inboxes GET /api/v1/accounts/:account_id/inboxes/ GET /api/v1/accounts/:account_id/inboxes/:inbox_id GET /api/v1/accounts/:account_id/inboxes/:inbox_id/agent_bot @@ -309,6 +323,7 @@ GET /api/v1/accounts/:account_id/labels/:tag_id/conversations GET /api/v1/accounts/:account_id/line_channels/ GET /api/v1/accounts/:account_id/live_reports/conversation_metrics GET /api/v1/accounts/:account_id/live_reports/grouped_conversation_metrics +GET /api/v1/accounts/:account_id/macros GET /api/v1/accounts/:account_id/macros/ GET /api/v1/accounts/:account_id/macros/:macro_id GET /api/v1/accounts/:account_id/microsoft/callback @@ -358,6 +373,7 @@ GET /api/v1/accounts/:account_id/reports/labels GET /api/v1/accounts/:account_id/reports/outgoing_messages_count GET /api/v1/accounts/:account_id/reports/summary GET /api/v1/accounts/:account_id/reports/teams +GET /api/v1/accounts/:account_id/saml_settings GET /api/v1/accounts/:account_id/saml_settings/:id GET /api/v1/accounts/:account_id/search GET /api/v1/accounts/:account_id/search/articles @@ -445,6 +461,7 @@ GET /app/*params GET /auth/validate_token GET /cable GET /enterprise/api/v1/accounts/:account_id/limits +GET /enterprise/api/v1/limits GET /google/callback GET /hc/:slug GET /hc/:slug/:locale @@ -567,6 +584,7 @@ PATCH /public/api/v1/csat_survey/:id PATCH /public/api/v1/inboxes/:inbox_id/contacts/:contact_id PATCH /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id PATCH /widget/contact +POST /api/v1/accounts POST /api/v1/accounts/ POST /api/v1/accounts/:account_id/actions/contact_merge POST /api/v1/accounts/:account_id/agent_bot_inboxes/ @@ -578,6 +596,7 @@ POST /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/reset_access_token POST /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/reset_secret POST /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/reset_token POST /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/trigger_configs/ +POST /api/v1/accounts/:account_id/agent_capacity_policies POST /api/v1/accounts/:account_id/agent_capacity_policies/ POST /api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits POST /api/v1/accounts/:account_id/agent_capacity_policies/:id/users @@ -591,6 +610,7 @@ POST /api/v1/accounts/:account_id/assignment_policies/:policy_id/inboxes POST /api/v1/accounts/:account_id/assignment_policies_v2 POST /api/v1/accounts/:account_id/assignment_policies_v2/:id/inboxes POST /api/v1/accounts/:account_id/assignment_policy +POST /api/v1/accounts/:account_id/automation_rules POST /api/v1/accounts/:account_id/automation_rules/ POST /api/v1/accounts/:account_id/automation_rules/:automation_id/clone POST /api/v1/accounts/:account_id/automation_rules/:automation_id/toggle_active @@ -607,6 +627,7 @@ POST /api/v1/accounts/:account_id/canned_responses POST /api/v1/accounts/:account_id/canned_responses/ POST /api/v1/accounts/:account_id/captain/assistant_responses/ POST /api/v1/accounts/:account_id/captain/assistant_responses/process +POST /api/v1/accounts/:account_id/captain/assistants POST /api/v1/accounts/:account_id/captain/assistants/ POST /api/v1/accounts/:account_id/captain/assistants/:assistant_id/documents/ POST /api/v1/accounts/:account_id/captain/assistants/:assistant_id/inboxes @@ -620,7 +641,9 @@ POST /api/v1/accounts/:account_id/captain/copilot/suggest_replies POST /api/v1/accounts/:account_id/captain/copilot/summarize POST /api/v1/accounts/:account_id/captain/copilot/translate POST /api/v1/accounts/:account_id/captain/copilot_messages/ +POST /api/v1/accounts/:account_id/captain/copilot_threads POST /api/v1/accounts/:account_id/captain/copilot_threads/ +POST /api/v1/accounts/:account_id/captain/copilot_threads/:thread_id/copilot_messages POST /api/v1/accounts/:account_id/captain/copilot_threads/:thread_id/copilot_messages/ POST /api/v1/accounts/:account_id/captain/copilot_threads/:thread_id/messages POST /api/v1/accounts/:account_id/captain/custom_tools/ @@ -639,12 +662,14 @@ POST /api/v1/accounts/:account_id/captain/tasks/summarize/stream POST /api/v1/accounts/:account_id/channels/facebook_channel/ POST /api/v1/accounts/:account_id/channels/facebook_channel/oauth_callback POST /api/v1/accounts/:account_id/channels/facebook_channel/reauthorize +POST /api/v1/accounts/:account_id/companies POST /api/v1/accounts/:account_id/companies/ POST /api/v1/accounts/:account_id/companies/:company_id/contacts POST /api/v1/accounts/:account_id/companies/:company_id/contacts/:contact_id POST /api/v1/accounts/:account_id/companies/:company_id/destroy_custom_attributes POST /api/v1/accounts/:account_id/companies/:company_id/notes POST /api/v1/accounts/:account_id/contact_merge +POST /api/v1/accounts/:account_id/contacts POST /api/v1/accounts/:account_id/contacts/ POST /api/v1/accounts/:account_id/contacts/:contact_id/call POST /api/v1/accounts/:account_id/contacts/:contact_id/contact_inboxes @@ -658,6 +683,7 @@ POST /api/v1/accounts/:account_id/contacts/export POST /api/v1/accounts/:account_id/contacts/filter POST /api/v1/accounts/:account_id/contacts/import POST /api/v1/accounts/:account_id/contacts/merge +POST /api/v1/accounts/:account_id/conversations POST /api/v1/accounts/:account_id/conversations/ POST /api/v1/accounts/:account_id/conversations/:conversation_id/assign POST /api/v1/accounts/:account_id/conversations/:conversation_id/assignments @@ -666,6 +692,7 @@ POST /api/v1/accounts/:account_id/conversations/:conversation_id/custom_attribut POST /api/v1/accounts/:account_id/conversations/:conversation_id/direct_uploads POST /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages/ POST /api/v1/accounts/:account_id/conversations/:conversation_id/labels +POST /api/v1/accounts/:account_id/conversations/:conversation_id/messages POST /api/v1/accounts/:account_id/conversations/:conversation_id/messages/ POST /api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id/retry POST /api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id/translate @@ -684,6 +711,7 @@ POST /api/v1/accounts/:account_id/conversations/filter POST /api/v1/accounts/:account_id/csat_survey_responses/:id/update_review_notes POST /api/v1/accounts/:account_id/custom_attribute_definitions/ POST /api/v1/accounts/:account_id/custom_filters/ +POST /api/v1/accounts/:account_id/custom_roles POST /api/v1/accounts/:account_id/custom_roles/ POST /api/v1/accounts/:account_id/dashboard_apps POST /api/v1/accounts/:account_id/dashboard_apps/:id/widgets @@ -696,6 +724,7 @@ POST /api/v1/accounts/:account_id/google_channels/oauth_callback POST /api/v1/accounts/:account_id/hooks/ POST /api/v1/accounts/:account_id/hooks/:id/process_event POST /api/v1/accounts/:account_id/inbox_members/ +POST /api/v1/accounts/:account_id/inboxes POST /api/v1/accounts/:account_id/inboxes/ POST /api/v1/accounts/:account_id/inboxes/:inbox_id/assignment_policy POST /api/v1/accounts/:account_id/inboxes/:inbox_id/assignment_policy/ @@ -731,6 +760,7 @@ POST /api/v1/accounts/:account_id/labels/ POST /api/v1/accounts/:account_id/labels/batch_add POST /api/v1/accounts/:account_id/labels/batch_remove POST /api/v1/accounts/:account_id/line_channels/ +POST /api/v1/accounts/:account_id/macros POST /api/v1/accounts/:account_id/macros/ POST /api/v1/accounts/:account_id/macros/:macro_id/clone POST /api/v1/accounts/:account_id/macros/:macro_id/execute @@ -763,6 +793,7 @@ POST /api/v1/accounts/:account_id/portals/:portal_id/categories/reorder POST /api/v1/accounts/:account_id/portals/:portal_id/folders/ POST /api/v1/accounts/:account_id/portals/:portal_id/members/ POST /api/v1/accounts/:account_id/portals/:portal_id/send_instructions +POST /api/v1/accounts/:account_id/saml_settings POST /api/v1/accounts/:account_id/saml_settings/ POST /api/v1/accounts/:account_id/saml_settings/:id/toggle_active POST /api/v1/accounts/:account_id/sla_policies @@ -840,6 +871,10 @@ POST /enterprise/api/v1/accounts/:account_id/checkout POST /enterprise/api/v1/accounts/:account_id/subscription POST /enterprise/api/v1/accounts/:account_id/toggle_deletion POST /enterprise/api/v1/accounts/:account_id/topup_checkout +POST /enterprise/api/v1/checkout +POST /enterprise/api/v1/subscription +POST /enterprise/api/v1/toggle_deletion +POST /enterprise/api/v1/topup_checkout POST /platform/api/v1/accounts POST /platform/api/v1/accounts/:account_id/account_users POST /platform/api/v1/agent_bots @@ -943,6 +978,7 @@ PUT /api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id PUT /api/v1/accounts/:account_id/portals/:portal_id/categories/:category_id PUT /api/v1/accounts/:account_id/portals/:portal_id/folders/:folder_id PUT /api/v1/accounts/:account_id/portals/:portal_id/members/:member_id +PUT /api/v1/accounts/:account_id/saml_settings PUT /api/v1/accounts/:account_id/saml_settings/:id PUT /api/v1/accounts/:account_id/settings PUT /api/v1/accounts/:account_id/sla_policies/:id @@ -971,4 +1007,4 @@ PUT /public/api/v1/csat_survey/:id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id PUT /widget/direct_uploads/:upload_uuid -TOTAL: 973 +TOTAL: 1009 diff --git a/docs/parity/route_parity.md b/docs/parity/route_parity.md index 9a0c33f2..31e01c91 100644 --- a/docs/parity/route_parity.md +++ b/docs/parity/route_parity.md @@ -7,7 +7,7 @@ Generated from: This report covers tracked frontend-critical Chatwoot routes from `reference/chatwoot/config/routes.rb`, including API v1 account routes, Captain/Copilot, assignment policies, widget/public APIs, and API v2 reports. Ruby is not installed in the workspace, so Chatwoot routes are sourced from static route declarations instead of `bin/rails routes`. -Summary: 442 exact, 0 method-compatible, 2 parameter-compatible, 0 missing out of 444 tracked critical routes. +Summary: 435 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 444 tracked critical routes. ## Missing Critical Routes @@ -25,12 +25,20 @@ These routes exist at the same path but with a compatible HTTP method. Rails res ## Parameter-Compatible Routes -These routes exist with equivalent method and path shape but differ in resource naming conventions or file extensions that need Chatwoot compatibility. See the Exact Routes table for routes now fully matched. +These routes exist with equivalent method and path shape but different parameter names or a Gin-compatible suffix dispatcher. They need handler/serializer parity review, and exact external path compatibility must be preserved. | Method | Chatwoot Path | GoChat Match | Controller | Source | Status | | --- | --- | --- | --- | --- | --- | +| DELETE | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/inbox_limits/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits/:limit_id` | `api/v1/accounts/agent_capacity_policies/inbox_limits#destroy` | `routes.rb:126` | parameter-compatible | +| DELETE | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/users/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/users/:user_id` | `api/v1/accounts/agent_capacity_policies/users#destroy` | `routes.rb:125` | parameter-compatible | +| GET | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/users` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/users` | `api/v1/accounts/agent_capacity_policies/users#index` | `routes.rb:125` | parameter-compatible | | GET | `/hc/:slug/articles/:article_slug.md` | `/hc/:slug/articles/:article_slug` | `public/api/v1/portals/articles#show_markdown` | `routes.rb:599` | parameter-compatible | | GET | `/hc/:slug/articles/:article_slug.png` | `/hc/:slug/articles/:article_slug` | `public/api/v1/portals/articles#tracking_pixel` | `routes.rb:598` | parameter-compatible | +| PATCH | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/inbox_limits/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits/:limit_id` | `api/v1/accounts/agent_capacity_policies/inbox_limits#update` | `routes.rb:126` | parameter-compatible | +| POST | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/inbox_limits` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits` | `api/v1/accounts/agent_capacity_policies/inbox_limits#create` | `routes.rb:126` | parameter-compatible | +| POST | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/users` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/users` | `api/v1/accounts/agent_capacity_policies/users#create` | `routes.rb:125` | parameter-compatible | +| PUT | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/inbox_limits/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits/:limit_id` | `api/v1/accounts/agent_capacity_policies/inbox_limits#update` | `routes.rb:126` | parameter-compatible | + ## Exact Critical Routes | Method | Chatwoot Path | GoChat Match | Controller | Source | Status | diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 7db593ab..6399467d 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -106,10 +106,6 @@ func Bootstrap(env string) (*App, error) { workerPool := worker.NewWorkerPool(db) automation.RegisterActionDeliveryJobs(workerPool, &dbProvider{db: db}) - service.RegisterConversationMaintenanceJobs(workerPool, db) - if _, err := service.EnqueueScheduledItemsTrigger(context.Background(), workerPool, time.Now()); err != nil { - applogger.L().Warnf("failed to enqueue initial scheduled item trigger: %v", err) - } // Step 5: Connect to Redis (ref: Chatwoot config/cable.yml) rdb, err := NewRedisClient(&cfg.Redis) @@ -330,6 +326,10 @@ func Bootstrap(env string) (*App, error) { // Create channel dispatcher for event-driven architecture (ref: Chatwoot Dispatcher) channelDispatcher := channel.NewDispatcher(workerPool) + service.RegisterConversationMaintenanceJobs(workerPool, db, channelDispatcher) + if _, err := service.EnqueueScheduledItemsTrigger(context.Background(), workerPool, time.Now()); err != nil { + applogger.L().Warnf("failed to enqueue initial scheduled item trigger: %v", err) + } // P9: AgentBot rule engine services botRuleService := automation.NewBotRuleService(&dbProvider{db: db}) @@ -649,7 +649,7 @@ func Bootstrap(env string) (*App, error) { profileService.SetConfirmationMailer(service.NewEnvProfileConfirmationMailer()) // Campaign + AutoAssignment services - campaignInternalSvc := campaign.NewCampaignService(db) + campaignInternalSvc := campaign.NewCampaignService(db, channelDispatcher) campaignService := service.NewCampaignService(campaignInternalSvc, campaignRepo) assignmentInternalSvc := autoassignment.NewAssignmentService(db, rdb) assignmentPolicyService := service.NewAssignmentPolicyService(assignmentPolicyRepo, inboxAssignmentPolicyRepo, assignmentInternalSvc) @@ -724,6 +724,7 @@ func Bootstrap(env string) (*App, error) { // Must be created before handlers so the hubTypingAdapter can reference it. wsHub := ws.NewHubSimple() wsRelay := wspkg.NewBroadcastRelay(rdb, wsHub) + eventPublisher := wspkg.NewEventPublisher(wsHub, nil, wsRelay) presenceTracker := wspkg.NewPresenceTracker(rdb, wsRelay) // Widget service + handler (M11 — WebWidget channel completion) @@ -735,7 +736,7 @@ func Bootstrap(env string) (*App, error) { widgetOfflineMessageRepo := repository.NewWidgetOfflineMessageRepo(db) widgetService := service.NewWidgetService(inboxRepo, contactRepo, contactInboxRepo, conversationRepo, messageRepo, widgetTypingAdapter, widgetThemeConfigRepo, preChatFormRepo, widgetFileUploadRepo, widgetOfflineMessageRepo, inboxMemberRepo, tagRepo, campaignRepo) widgetService.SetWorkerPool(workerPool) - widgetHandler := widget.NewHandler(widgetService) + widgetHandler := widget.NewHandler(widgetService).WithEventPublisher(eventPublisher) // Upload: DirectUpload repo + service + handler (account-level + widget direct uploads) directUploadRepo := repository.NewDirectUploadRepo(db) @@ -753,7 +754,7 @@ func Bootstrap(env string) (*App, error) { SAML: v1.NewSAMLHandler(samlService, jwtService, refreshStore, ssoSessionStore, &cfg.SAML), Account: v1.NewAccountHandler(accountService), EnterpriseAccount: v1.NewEnterpriseAccountHandler(accountService), - Contact: v1.NewContactHandler(contactService, contactInboxService, contactMergeService, contactNoteService, conversationService).WithContactPresence(presenceTracker), + Contact: v1.NewContactHandler(contactService, contactInboxService, contactMergeService, contactNoteService, conversationService).WithContactPresence(presenceTracker).WithEventPublisher(eventPublisher), Conversation: v1.NewConversationHandler(conversationService, messageService).WithAuditService(auditService).WithContactPresence(presenceTracker), Inbox: v1.NewInboxHandler(inboxService).WithAuditService(auditService), InboxMember: v1.NewInboxMemberHandler(inboxMemberService), @@ -773,7 +774,7 @@ func Bootstrap(env string) (*App, error) { EmailWebhook: emailWebhookHandler, Message: v1.NewMessageHandler(messageService), Profile: v1.NewProfileHandler(profileService), - Notification: v1.NewNotificationHandler(notificationService), + Notification: v1.NewNotificationHandler(notificationService).WithEventPublisher(eventPublisher), PlatformApp: v1.NewPlatformAppHandler(platformAppService), Team: v1.NewTeamHandler(teamService), CaptainAssistant: v1.NewCaptainAssistantHandler(captainAssistantService), @@ -845,7 +846,7 @@ func Bootstrap(env string) (*App, error) { ConversationParticipant: v1.NewConversationParticipantHandler(conversationParticipantService), DraftMessage: v1.NewDraftMessageHandler(draftMessageService), // G4: Companies module (CRUD + search + nested contacts/conversations/notes) - Company: v1.NewCompanyHandler(companyService), + Company: v1.NewCompanyHandler(companyService).WithEventPublisher(eventPublisher), // Custom attributes + custom filters CustomAttributeDefinition: v1.NewCustomAttributeDefinitionHandler(customAttributeDefinitionService), CustomAttributeValue: v1.NewCustomAttributeValueHandler(customAttributeValueService), diff --git a/internal/app/redis.go b/internal/app/redis.go index 1e588ef8..8cd570fc 100644 --- a/internal/app/redis.go +++ b/internal/app/redis.go @@ -7,9 +7,9 @@ import ( "strings" "time" - "github.com/redis/go-redis/v9" "github.com/gochat/gochat/internal/config" "github.com/gochat/gochat/pkg/logger" + "github.com/redis/go-redis/v9" ) // NewRedisClient creates a Redis client for caching, Pub/Sub, and sessions. @@ -23,7 +23,11 @@ func NewRedisClient(cfg *config.RedisConfig) (*redis.Client, error) { if cfg.Password != "" { opts.Password = cfg.Password } - opts.PoolSize = 10 + poolSize := cfg.PoolSize + if poolSize <= 0 { + poolSize = 50 + } + opts.PoolSize = poolSize client := redis.NewClient(opts) @@ -35,7 +39,7 @@ func NewRedisClient(cfg *config.RedisConfig) (*redis.Client, error) { return nil, fmt.Errorf("failed to connect to Redis: %w", err) } - logger.L().Infof("Connected to Redis: %s (pool=%d)", cfg.URL, 10) + logger.L().Infof("Connected to Redis: %s (pool=%d)", cfg.URL, poolSize) return client, nil } diff --git a/internal/campaign/service.go b/internal/campaign/service.go index 96c2d63e..4fd31408 100644 --- a/internal/campaign/service.go +++ b/internal/campaign/service.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" + "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" applogger "github.com/gochat/gochat/pkg/logger" "gorm.io/gorm" @@ -13,11 +14,16 @@ import ( // CampaignService provides business logic for campaign operations. // Reference: Chatwoot CampaignService — CRUD + campaign triggering. type CampaignService struct { - db *gorm.DB + db *gorm.DB + dispatcher *channel.Dispatcher } -func NewCampaignService(db *gorm.DB) *CampaignService { - return &CampaignService{db: db} +func NewCampaignService(db *gorm.DB, dispatchers ...*channel.Dispatcher) *CampaignService { + var dispatcher *channel.Dispatcher + if len(dispatchers) > 0 { + dispatcher = dispatchers[0] + } + return &CampaignService{db: db, dispatcher: dispatcher} } // Create creates a new campaign. @@ -93,32 +99,35 @@ func (s *CampaignService) TriggerCampaign(ctx context.Context, campaignID uint) return nil } - builder := NewCampaignConversationBuilder(s.db) + builder := NewCampaignConversationBuilder(s.db, s.dispatcher) return builder.Build(ctx, campaign) } // CampaignConversationBuilder builds a conversation from a campaign trigger. // Reference: Chatwoot CampaignListener — on campaign_triggered event -> builds conversation. type CampaignConversationBuilder struct { - db *gorm.DB + db *gorm.DB + dispatcher *channel.Dispatcher } -func NewCampaignConversationBuilder(db *gorm.DB) *CampaignConversationBuilder { - return &CampaignConversationBuilder{db: db} +func NewCampaignConversationBuilder(db *gorm.DB, dispatchers ...*channel.Dispatcher) *CampaignConversationBuilder { + var dispatcher *channel.Dispatcher + if len(dispatchers) > 0 { + dispatcher = dispatchers[0] + } + return &CampaignConversationBuilder{db: db, dispatcher: dispatcher} } // Build creates a new conversation from the campaign for each contact in the audience. // The audience field (JSONB) contains contact IDs or filter criteria. func (b *CampaignConversationBuilder) Build(ctx context.Context, campaign *Campaign) error { - // Parse audience to extract contact IDs - var audienceData struct { - ContactIDs []uint `json:"contact_ids"` - } - if err := json.Unmarshal([]byte(campaign.Audience), &audienceData); err != nil { + contactIDs, err := campaignAudienceContactIDs(campaign.Audience) + if err != nil { return fmt.Errorf("campaign: parse audience: %w", err) } + inbox := b.loadInbox(ctx, campaign) - for _, contactID := range audienceData.ContactIDs { + for _, contactID := range contactIDs { conv := &model.Conversation{ AccountID: campaign.AccountID, InboxID: campaign.InboxID, @@ -137,6 +146,8 @@ func (b *CampaignConversationBuilder) Build(ctx context.Context, campaign *Campa "campaign_id", campaign.ID, "contact_id", contactID, "error", err) continue } + b.dispatch(ctx, channel.EventConversationCreated, campaign, inbox, conv, nil) + b.dispatch(ctx, channel.EventConversationOpened, campaign, inbox, conv, nil) // Create the initial campaign message in the conversation msg := &model.Message{ @@ -157,6 +168,8 @@ func (b *CampaignConversationBuilder) Build(ctx context.Context, campaign *Campa "campaign_id", campaign.ID, "conversation_id", conv.ID, "error", err) continue } + b.dispatch(ctx, channel.EventMessageCreated, campaign, inbox, conv, msg) + b.dispatch(ctx, channel.EventMessageOutgoing, campaign, inbox, conv, msg) applogger.L().Info("campaign: created conversation from campaign", "campaign_id", campaign.ID, "conversation_id", conv.ID, "contact_id", contactID) @@ -164,3 +177,55 @@ func (b *CampaignConversationBuilder) Build(ctx context.Context, campaign *Campa return nil } + +func (b *CampaignConversationBuilder) loadInbox(ctx context.Context, c *Campaign) *model.Inbox { + if c.Inbox.ID != 0 { + return &c.Inbox + } + var inbox model.Inbox + if err := b.db.WithContext(ctx).Where("account_id = ? AND id = ?", c.AccountID, c.InboxID).First(&inbox).Error; err != nil { + return nil + } + return &inbox +} + +func (b *CampaignConversationBuilder) dispatch(ctx context.Context, eventType channel.EventType, campaign *Campaign, inbox *model.Inbox, conversation *model.Conversation, message *model.Message) { + if b.dispatcher == nil || campaign == nil || conversation == nil { + return + } + channelType := channel.ChannelAPI + inboxID := campaign.InboxID + if inbox != nil { + channelType = channel.ChannelType(inbox.ChannelType) + inboxID = inbox.ID + } + event := channel.NewChannelEvent(eventType, channelType, campaign.AccountID, inboxID) + event.ConversationID = conversation.ID + event.ContactID = conversation.ContactID + event.Data["campaign_id"] = campaign.ID + event.Data["conversation"] = conversation + if inbox != nil { + event.Data["inbox"] = inbox + } + if message != nil { + event.Data["message"] = message + } + if err := b.dispatcher.DispatchAsync(ctx, event); err != nil { + applogger.L().Warn("campaign: failed to dispatch campaign event", "campaign_id", campaign.ID, "event", eventType, "error", err) + } +} + +func campaignAudienceContactIDs(raw string) ([]uint, error) { + var audienceData struct { + ContactIDs []uint `json:"contact_ids"` + } + if err := json.Unmarshal([]byte(raw), &audienceData); err == nil { + return audienceData.ContactIDs, nil + } + + var frontendRules []map[string]any + if err := json.Unmarshal([]byte(raw), &frontendRules); err != nil { + return nil, err + } + return []uint{}, nil +} diff --git a/internal/channel/provider/telegram_test.go b/internal/channel/provider/telegram_test.go new file mode 100644 index 00000000..1afe20f9 --- /dev/null +++ b/internal/channel/provider/telegram_test.go @@ -0,0 +1,93 @@ +package provider + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/go-resty/resty/v2" + "github.com/gochat/gochat/internal/channel" + "github.com/gochat/gochat/internal/model" +) + +func TestTelegramProvider_CreateChannelReturnsWebhookSetupFailure(t *testing.T) { + provider := NewTelegramProvider() + provider.client = fakeTelegramRestyClient(t, map[string]string{ + "/bot123:token/getMe": `{"ok":true,"result":{"id":42,"first_name":"Support Bot","username":"support_bot"}}`, + "/bot123:token/deleteWebhook": `{"ok":true,"result":true}`, + "/bot123:token/setWebhook": `{"ok":false,"description":"webhook URL is invalid"}`, + }) + + _, err := provider.CreateChannel(context.Background(), 1, channel.ChannelConfig{"bot_token": "123:token"}) + if err == nil || !strings.Contains(err.Error(), "telegram webhook setup failed") || !strings.Contains(err.Error(), "webhook URL is invalid") { + t.Fatalf("expected webhook setup failure, got %v", err) + } +} + +func TestTelegramProvider_OnCreateFlagsReauthorizationOnWebhookFailure(t *testing.T) { + provider := NewTelegramProvider() + provider.client = fakeTelegramRestyClient(t, map[string]string{ + "/bot123:token/deleteWebhook": `{"ok":true,"result":true}`, + "/bot123:token/setWebhook": `{"ok":false,"description":"webhook URL is invalid"}`, + }) + + config, err := provider.OnCreate(context.Background(), &model.Inbox{ChannelType: "telegram"}, channel.ChannelConfig{"bot_token": "123:token"}) + if err != nil { + t.Fatalf("expected OnCreate to keep inbox creation non-fatal, got %v", err) + } + if config["reauthorization_required"] != true { + t.Fatalf("expected reauthorization_required=true, got %#v", config) + } + if _, ok := config["webhook_url"]; ok { + t.Fatalf("expected webhook_url to be omitted on setup failure, got %#v", config["webhook_url"]) + } +} + +func TestTelegramProvider_OnCreateStoresWebhookURLOnSuccess(t *testing.T) { + t.Setenv("FRONTEND_URL", "https://app.example.test") + provider := NewTelegramProvider() + provider.client = fakeTelegramRestyClient(t, map[string]string{ + "/bot123:token/deleteWebhook": `{"ok":true,"result":true}`, + "/bot123:token/setWebhook": `{"ok":true,"result":true}`, + }) + + config, err := provider.OnCreate(context.Background(), &model.Inbox{ChannelType: "telegram"}, channel.ChannelConfig{"bot_token": "123:token"}) + if err != nil { + t.Fatalf("expected successful OnCreate, got %v", err) + } + if config["reauthorization_required"] != false { + t.Fatalf("expected reauthorization_required=false, got %#v", config) + } + if config["webhook_url"] != "https://app.example.test/webhooks/telegram/123:token" { + t.Fatalf("unexpected webhook_url: %#v", config["webhook_url"]) + } +} + +func fakeTelegramRestyClient(t *testing.T, responses map[string]string) *resty.Client { + t.Helper() + client := resty.New() + client.SetTimeout(2 * time.Second) + client.SetRetryCount(0) + client.SetTransport(roundTripFunc(func(req *http.Request) (*http.Response, error) { + body, ok := responses[req.URL.Path] + if !ok { + t.Fatalf("unexpected telegram API request: %s %s", req.Method, req.URL.Path) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + })) + return client +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} diff --git a/internal/channel/whatsapp/webhook_handler.go b/internal/channel/whatsapp/webhook_handler.go index 083a2678..655ee809 100644 --- a/internal/channel/whatsapp/webhook_handler.go +++ b/internal/channel/whatsapp/webhook_handler.go @@ -44,6 +44,7 @@ type WebhookHandler struct { type IncomingPersister interface { PersistIncoming(ctx context.Context, inbox *model.Inbox, msg *channel.IncomingMessage) (interface{}, error) UpdateMessageStatus(ctx context.Context, inbox *model.Inbox, sourceID string, status model.MessageStatus, occurredAt *time.Time) error + UpdateMessageStatusWithError(ctx context.Context, inbox *model.Inbox, sourceID string, status model.MessageStatus, occurredAt *time.Time, externalError string) error } // NewWebhookHandler creates a WhatsApp webhook handler. @@ -178,7 +179,7 @@ func (h *WebhookHandler) persistStatusUpdates(ctx context.Context, inbox *model. if parsed := parseUnixTimestamp(status.Timestamp); parsed != nil { occurredAt = parsed } - if err := h.persister.UpdateMessageStatus(ctx, inbox, status.ID, mapped, occurredAt); err != nil { + if err := h.persister.UpdateMessageStatusWithError(ctx, inbox, status.ID, mapped, occurredAt, whatsappExternalError(status)); err != nil { applogger.L().Error("WhatsApp webhook: status persistence failed", "message_id", status.ID, "status", status.Status, "error", err) } } @@ -186,6 +187,23 @@ func (h *WebhookHandler) persistStatusUpdates(ctx context.Context, inbox *model. } } +func whatsappExternalError(status WAStatus) string { + if status.Status != "failed" || len(status.Errors) == 0 { + return "" + } + errorInfo := status.Errors[0] + if errorInfo.Code != 0 && errorInfo.Title != "" { + return fmt.Sprintf("%d - %s", errorInfo.Code, errorInfo.Title) + } + if errorInfo.Code != 0 && errorInfo.Message != "" { + return fmt.Sprintf("%d - %s", errorInfo.Code, errorInfo.Message) + } + if errorInfo.Title != "" { + return errorInfo.Title + } + return errorInfo.Message +} + func mapWhatsAppMessageStatus(status string) (model.MessageStatus, bool) { switch status { case "sent": diff --git a/internal/config/config.go b/internal/config/config.go index 28badfde..cb4851e9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -144,6 +144,7 @@ type RedisConfig struct { Password string `mapstructure:"password"` DB int `mapstructure:"db"` URL string `mapstructure:"url"` + PoolSize int `mapstructure:"pool_size"` } type JWTConfig struct { @@ -417,7 +418,7 @@ func Load() (*Config, error) { viper.SetDefault("csrf.cookie_same_site", "Strict") viper.SetDefault("csrf.cookie_path", "/") viper.SetDefault("csrf.expiry_seconds", 3600) - viper.SetDefault("csrf.skip_paths", []string{"/api/v1/auth/", "/health", "/api/v1/saml/"}) + viper.SetDefault("csrf.skip_paths", []string{"/auth/", "/api/v1/", "/platform/api/", "/public/api/", "/widget/", "/webhooks/", "/health"}) // Set defaults for session management viper.SetDefault("session.enabled", true) @@ -631,6 +632,7 @@ func LoadWithEnv(env string) (*Config, error) { "GOCHAT_REDIS_HOST": "redis.host", "GOCHAT_REDIS_PORT": "redis.port", "GOCHAT_REDIS_PASSWORD": "redis.password", + "GOCHAT_REDIS_POOL_SIZE": "redis.pool_size", "GOCHAT_JWT_SECRET": "jwt.secret", "JWT_SECRET": "jwt.secret", // Alias for compatibility (no prefix) "GOCHAT_JWT_EXPIRY_HOURS": "jwt.expiry_hours", @@ -768,6 +770,7 @@ func setDefaults(v *viper.Viper) { v.SetDefault("redis.host", "localhost") v.SetDefault("redis.port", 6379) v.SetDefault("redis.db", 0) + v.SetDefault("redis.pool_size", 50) v.SetDefault("jwt.expiry_hours", 72) @@ -810,7 +813,7 @@ func setDefaults(v *viper.Viper) { v.SetDefault("csrf.cookie_same_site", "Strict") v.SetDefault("csrf.cookie_path", "/") v.SetDefault("csrf.expiry_seconds", 3600) - v.SetDefault("csrf.skip_paths", []string{"/api/v1/auth/", "/health", "/api/v1/saml/"}) + v.SetDefault("csrf.skip_paths", []string{"/auth/", "/api/v1/", "/platform/api/", "/public/api/", "/widget/", "/webhooks/", "/health"}) // Session defaults v.SetDefault("session.enabled", true) diff --git a/internal/handler/api/v1/account_saml_settings_handler.go b/internal/handler/api/v1/account_saml_settings_handler.go index 17787d47..7d10a07f 100644 --- a/internal/handler/api/v1/account_saml_settings_handler.go +++ b/internal/handler/api/v1/account_saml_settings_handler.go @@ -5,7 +5,10 @@ package v1 // Pattern follows Chatwoot AccountSamlSettings API (super_admin scoped). import ( + "crypto/sha1" + "encoding/hex" "encoding/json" + "errors" "net/http" "strconv" @@ -13,8 +16,8 @@ import ( "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" - "github.com/gochat/gochat/pkg/response" applogger "github.com/gochat/gochat/pkg/logger" + "github.com/gochat/gochat/pkg/response" ) // AccountSamlSettingsHandler handles account-scoped SAML configuration endpoints. @@ -61,10 +64,7 @@ func (h *AccountSamlSettingsHandler) Get(c *gin.Context) { return } - c.JSON(http.StatusOK, response.APIResponse{ - Success: true, - Data: settings, - }) + c.JSON(http.StatusOK, serializeSamlSettings(settings)) } // Create creates SAML settings for an account. @@ -83,8 +83,8 @@ func (h *AccountSamlSettingsHandler) Create(c *gin.Context) { return } - var req CreateSamlSettingsRequest - if err := c.ShouldBindJSON(&req); err != nil { + req, err := bindCreateSamlSettingsRequest(c) + if err != nil { c.JSON(http.StatusBadRequest, response.APIResponse{ Success: false, Error: &response.ErrorBody{ @@ -122,10 +122,7 @@ func (h *AccountSamlSettingsHandler) Create(c *gin.Context) { } applogger.L().Infof("SAML settings created for account %d", accountID) - c.JSON(http.StatusCreated, response.APIResponse{ - Success: true, - Data: settings, - }) + c.JSON(http.StatusCreated, serializeSamlSettings(&settings)) } // Update updates SAML settings for an account. @@ -144,8 +141,8 @@ func (h *AccountSamlSettingsHandler) Update(c *gin.Context) { return } - var req UpdateSamlSettingsRequest - if err := c.ShouldBindJSON(&req); err != nil { + req, err := bindUpdateSamlSettingsRequest(c) + if err != nil { c.JSON(http.StatusBadRequest, response.APIResponse{ Success: false, Error: &response.ErrorBody{ @@ -197,10 +194,7 @@ func (h *AccountSamlSettingsHandler) Update(c *gin.Context) { // Return updated settings settings, _ := h.repo.GetByAccount(uint(accountID)) applogger.L().Infof("SAML settings updated for account %d", accountID) - c.JSON(http.StatusOK, response.APIResponse{ - Success: true, - Data: settings, - }) + c.JSON(http.StatusOK, serializeSamlSettings(settings)) } // Delete removes SAML settings for an account. @@ -277,8 +271,8 @@ func (h *AccountSamlSettingsHandler) ToggleActive(c *gin.Context) { Success: true, Data: map[string]interface{}{ "account_id": accountID, - "active": req.Active, - "status": status, + "active": req.Active, + "status": status, }, }) } @@ -287,6 +281,8 @@ func (h *AccountSamlSettingsHandler) ToggleActive(c *gin.Context) { // CreateSamlSettingsRequest is the request body for creating SAML settings. type CreateSamlSettingsRequest struct { + SsoURL string `json:"sso_url"` + Certificate string `json:"certificate"` IdpEntityID string `json:"idp_entity_id" binding:"required"` IdpSsoTargetURL string `json:"idp_sso_target_url" binding:"required"` IdpSloTargetURL string `json:"idp_slo_target_url"` @@ -300,6 +296,7 @@ type CreateSamlSettingsRequest struct { // ToModel converts a CreateSamlSettingsRequest to an AccountSamlSettings model. func (req *CreateSamlSettingsRequest) ToModel(accountID uint) model.AccountSamlSettings { + req.normalizeChatwootAliases() return model.AccountSamlSettings{ AccountID: accountID, IdpEntityID: req.IdpEntityID, @@ -314,9 +311,20 @@ func (req *CreateSamlSettingsRequest) ToModel(accountID uint) model.AccountSamlS } } +func (req *CreateSamlSettingsRequest) normalizeChatwootAliases() { + if req.IdpSsoTargetURL == "" { + req.IdpSsoTargetURL = req.SsoURL + } + if req.IdpCertificate == "" { + req.IdpCertificate = req.Certificate + } +} + // UpdateSamlSettingsRequest is the request body for updating SAML settings. // All fields are optional — only non-nil/non-zero fields will be updated. type UpdateSamlSettingsRequest struct { + SsoURL *string `json:"sso_url"` + Certificate *string `json:"certificate"` IdpEntityID *string `json:"idp_entity_id"` IdpSsoTargetURL *string `json:"idp_sso_target_url"` IdpSloTargetURL *string `json:"idp_slo_target_url"` @@ -331,6 +339,12 @@ type UpdateSamlSettingsRequest struct { // ToUpdatesMap converts an UpdateSamlSettingsRequest to a map of fields to update. func (req *UpdateSamlSettingsRequest) ToUpdatesMap() map[string]interface{} { updates := make(map[string]interface{}) + if req.IdpSsoTargetURL == nil { + req.IdpSsoTargetURL = req.SsoURL + } + if req.IdpCertificate == nil { + req.IdpCertificate = req.Certificate + } if req.IdpEntityID != nil { updates["idp_entity_id"] = *req.IdpEntityID } @@ -370,9 +384,79 @@ type ToggleActiveRequest struct { // Only accessible to account administrators (enforced by AccountScope middleware in router). // Reference: Chatwoot AccountSamlSettings API — enterprise SSO configuration func RegisterAccountSamlSettingsRoutes(g *gin.RouterGroup, h *AccountSamlSettingsHandler) { - g.POST("/", h.Create) // Create a new SAML config - g.GET("/:id", h.Get) // Get a specific SAML config - g.PUT("/:id", h.Update) // Update a SAML config - g.DELETE("/:id", h.Delete) // Delete a SAML config + g.GET("", h.Get) // Chatwoot frontend: fetch account SAML config + g.POST("", h.Create) // Chatwoot frontend: create account SAML config + g.PUT("", h.Update) // Chatwoot frontend: update account SAML config + g.DELETE("", h.Delete) // Chatwoot frontend: delete account SAML config + g.POST("/", h.Create) // Backward compatibility for trailing slash clients + g.GET("/:id", h.Get) // Backward compatibility for id-based callers + g.PUT("/:id", h.Update) // Backward compatibility for id-based callers + g.DELETE("/:id", h.Delete) // Backward compatibility for id-based callers g.POST("/:id/toggle_active", h.ToggleActive) // Enable/disable SAML for a config -} \ No newline at end of file +} + +func bindCreateSamlSettingsRequest(c *gin.Context) (CreateSamlSettingsRequest, error) { + req := CreateSamlSettingsRequest{} + if err := bindSamlSettingsPayload(c, &req); err != nil { + return req, err + } + req.normalizeChatwootAliases() + if req.IdpEntityID == "" || req.IdpSsoTargetURL == "" || req.IdpCertificate == "" { + return req, errors.New("idp_entity_id, idp_sso_target_url, and idp_certificate are required") + } + return req, nil +} + +func bindUpdateSamlSettingsRequest(c *gin.Context) (UpdateSamlSettingsRequest, error) { + req := UpdateSamlSettingsRequest{} + return req, bindSamlSettingsPayload(c, &req) +} + +func bindSamlSettingsPayload(c *gin.Context, req interface{}) error { + var payload map[string]json.RawMessage + if err := json.NewDecoder(c.Request.Body).Decode(&payload); err != nil { + return err + } + + if nested, ok := payload["saml_settings"]; ok { + return json.Unmarshal(nested, req) + } + + flat, err := json.Marshal(payload) + if err != nil { + return err + } + return json.Unmarshal(flat, req) +} + +func serializeSamlSettings(settings *model.AccountSamlSettings) gin.H { + if settings == nil { + return gin.H{} + } + + return gin.H{ + "id": settings.ID, + "account_id": settings.AccountID, + "idp_entity_id": settings.IdpEntityID, + "idp_sso_target_url": settings.IdpSsoTargetURL, + "idp_slo_target_url": settings.IdpSloTargetURL, + "idp_certificate": settings.IdpCertificate, + "sso_url": settings.IdpSsoTargetURL, + "certificate": settings.IdpCertificate, + "sp_entity_id": settings.SpEntityID, + "sp_x509_certificate": settings.SpX509Certificate, + "role_mappings": settings.RoleMappings, + "active": settings.Active, + "fingerprint": samlCertificateFingerprint(settings.IdpCertificate), + "created_at": settings.CreatedAt, + "updated_at": settings.UpdatedAt, + } +} + +func samlCertificateFingerprint(certificate string) string { + if certificate == "" { + return "" + } + sum := sha1.Sum([]byte(certificate)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/handler/api/v1/account_saml_settings_handler_test.go b/internal/handler/api/v1/account_saml_settings_handler_test.go index d7af5a1f..b40284d9 100644 --- a/internal/handler/api/v1/account_saml_settings_handler_test.go +++ b/internal/handler/api/v1/account_saml_settings_handler_test.go @@ -2,6 +2,7 @@ package v1 import ( "bytes" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -33,12 +34,7 @@ func (s *AccountSamlSettingsHandlerTestSuite) SetupSuite() { gin.SetMode(gin.TestMode) r := gin.New() - accountGroup := r.Group("/api/v1/accounts/:account_id") - accountGroup.GET("/saml_settings", s.handler.Get) - accountGroup.POST("/saml_settings", s.handler.Create) - accountGroup.PUT("/saml_settings/:saml_setting_id", s.handler.Update) - accountGroup.DELETE("/saml_settings/:saml_setting_id", s.handler.Delete) - accountGroup.POST("/saml_settings/:saml_setting_id/toggle_active", s.handler.ToggleActive) + RegisterAccountSamlSettingsRoutes(r.Group("/api/v1/accounts/:account_id/saml_settings"), s.handler) s.router = r s.account = &model.Account{Name: "TestAccount"} @@ -79,6 +75,71 @@ func (s *AccountSamlSettingsHandlerTestSuite) TestGet_Success() { req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/saml_settings", s.account.ID), nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) + + var payload map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload)) + s.Equal("https://sso.example.com", payload["sso_url"]) + s.Equal("https://sso.example.com", payload["idp_sso_target_url"]) + s.Equal("entity-id", payload["idp_entity_id"]) + s.NotContains(payload, "data") +} + +func (s *AccountSamlSettingsHandlerTestSuite) TestChatwootFrontendCollectionCRUDPayloads() { + createBody := bytes.NewBufferString(`{"saml_settings":{"sso_url":"https://idp.example.com/saml","certificate":"-----BEGIN CERTIFICATE-----chatwoot-----END CERTIFICATE-----","idp_entity_id":"chatwoot-idp","role_mappings":{}}}`) + createReq := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/saml_settings", s.account.ID), createBody) + createReq.Header.Set("Content-Type", "application/json") + createRecorder := httptest.NewRecorder() + s.router.ServeHTTP(createRecorder, createReq) + s.Equal(http.StatusCreated, createRecorder.Code) + + var created map[string]interface{} + s.Require().NoError(json.Unmarshal(createRecorder.Body.Bytes(), &created)) + s.NotZero(created["id"]) + s.Equal("https://idp.example.com/saml", created["sso_url"]) + s.Equal("https://idp.example.com/saml", created["idp_sso_target_url"]) + s.Equal("-----BEGIN CERTIFICATE-----chatwoot-----END CERTIFICATE-----", created["certificate"]) + s.Equal("-----BEGIN CERTIFICATE-----chatwoot-----END CERTIFICATE-----", created["idp_certificate"]) + s.Equal("chatwoot-idp", created["idp_entity_id"]) + s.NotEmpty(created["fingerprint"]) + s.NotContains(created, "data") + s.NotContains(created, "success") + + getReq := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/saml_settings", s.account.ID), nil) + getRecorder := httptest.NewRecorder() + s.router.ServeHTTP(getRecorder, getReq) + s.Equal(http.StatusOK, getRecorder.Code) + + var fetched map[string]interface{} + s.Require().NoError(json.Unmarshal(getRecorder.Body.Bytes(), &fetched)) + s.Equal(created["id"], fetched["id"]) + s.Equal("https://idp.example.com/saml", fetched["sso_url"]) + + updateBody := bytes.NewBufferString(`{"saml_settings":{"sso_url":"https://idp.example.com/updated","certificate":"updated-certificate","idp_entity_id":"updated-idp"}}`) + updateReq := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/accounts/%d/saml_settings", s.account.ID), updateBody) + updateReq.Header.Set("Content-Type", "application/json") + updateRecorder := httptest.NewRecorder() + s.router.ServeHTTP(updateRecorder, updateReq) + s.Equal(http.StatusOK, updateRecorder.Code) + + var updated map[string]interface{} + s.Require().NoError(json.Unmarshal(updateRecorder.Body.Bytes(), &updated)) + s.Equal(created["id"], updated["id"]) + s.Equal("https://idp.example.com/updated", updated["sso_url"]) + s.Equal("https://idp.example.com/updated", updated["idp_sso_target_url"]) + s.Equal("updated-certificate", updated["certificate"]) + s.Equal("updated-certificate", updated["idp_certificate"]) + s.Equal("updated-idp", updated["idp_entity_id"]) + s.NotContains(updated, "data") + + deleteReq := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/v1/accounts/%d/saml_settings", s.account.ID), nil) + deleteRecorder := httptest.NewRecorder() + s.router.ServeHTTP(deleteRecorder, deleteReq) + s.Equal(http.StatusOK, deleteRecorder.Code) + + afterDeleteReq := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/saml_settings", s.account.ID), nil) + afterDeleteRecorder := httptest.NewRecorder() + s.router.ServeHTTP(afterDeleteRecorder, afterDeleteReq) + s.Equal(http.StatusNotFound, afterDeleteRecorder.Code) } func (s *AccountSamlSettingsHandlerTestSuite) TestCreate_InvalidAccountID() { @@ -111,7 +172,7 @@ func (s *AccountSamlSettingsHandlerTestSuite) TestCreate_MissingRequiredFields() func (s *AccountSamlSettingsHandlerTestSuite) TestUpdate_InvalidAccountID() { w := httptest.NewRecorder() body := bytes.NewBufferString(`{"idp_entity_id":"updated-eid"}`) - req := httptest.NewRequest(http.MethodPut, "/api/v1/accounts/abc/saml_settings/1", body) + req := httptest.NewRequest(http.MethodPut, "/api/v1/accounts/abc/saml_settings", body) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) s.Equal(http.StatusBadRequest, w.Code) @@ -131,6 +192,11 @@ func (s *AccountSamlSettingsHandlerTestSuite) TestUpdate_Success() { req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) + + var payload map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload)) + s.Equal("updated-eid", payload["idp_entity_id"]) + s.NotContains(payload, "data") } func (s *AccountSamlSettingsHandlerTestSuite) TestUpdate_NotFound() { @@ -145,7 +211,7 @@ func (s *AccountSamlSettingsHandlerTestSuite) TestUpdate_NotFound() { func (s *AccountSamlSettingsHandlerTestSuite) TestDelete_InvalidAccountID() { w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodDelete, "/api/v1/accounts/abc/saml_settings/1", nil) + req := httptest.NewRequest(http.MethodDelete, "/api/v1/accounts/abc/saml_settings", nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusBadRequest, w.Code) } @@ -189,4 +255,4 @@ func (s *AccountSamlSettingsHandlerTestSuite) TestToggleActive_Success() { req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/agent_capacity_handler.go b/internal/handler/api/v1/agent_capacity_handler.go index 1a4ddedb..bd77863c 100644 --- a/internal/handler/api/v1/agent_capacity_handler.go +++ b/internal/handler/api/v1/agent_capacity_handler.go @@ -371,7 +371,9 @@ func (h *AgentCapacityHandler) DeleteUser(c *gin.Context) { func RegisterAgentCapacityRoutes(rg *gin.RouterGroup, h *AgentCapacityHandler) { policies := rg.Group("/agent_capacity_policies") { + policies.GET("", h.List) policies.GET("/", h.List) + policies.POST("", h.Create) policies.POST("/", h.Create) policies.GET("/:id", h.Get) policies.PUT("/:id", h.Update) diff --git a/internal/handler/api/v1/agent_capacity_handler_test.go b/internal/handler/api/v1/agent_capacity_handler_test.go index 71d54cbe..feb1bd26 100644 --- a/internal/handler/api/v1/agent_capacity_handler_test.go +++ b/internal/handler/api/v1/agent_capacity_handler_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "github.com/gin-gonic/gin" @@ -27,6 +28,20 @@ type AgentCapacityHandlerTestSuite struct { account *model.Account } +func assertErrorEnvelope(t *testing.T, body []byte, code string, messageContains string) { + t.Helper() + var payload map[string]any + assert.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, false, payload["success"]) + errorPayload, ok := payload["error"].(map[string]any) + if !assert.True(t, ok, "expected error envelope in %s", string(body)) { + return + } + assert.Equal(t, code, errorPayload["code"]) + message, _ := errorPayload["message"].(string) + assert.True(t, strings.Contains(message, messageContains), "expected message %q to contain %q", message, messageContains) +} + func (s *AgentCapacityHandlerTestSuite) SetupSuite() { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{ @@ -190,6 +205,14 @@ func (s *AgentCapacityHandlerTestSuite) TestChatwootPolicyInboxLimitAndUserFlow( s.Require().Equal(float64(11), limit["conversation_limit"]) s.Require().Equal("Priority", limit["inbox_name"]) + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodPatch, fmt.Sprintf("/api/v1/accounts/%d/agent_capacity_policies/%d/inbox_limits/%d", s.account.ID, policyID, limitID), bytes.NewBufferString(`{"conversation_limit":13}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + s.Require().Equal(http.StatusOK, w.Code) + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &limit)) + s.Require().Equal(float64(13), limit["conversation_limit"]) + w = httptest.NewRecorder() body = fmt.Sprintf(`{"user_id":%d}`, user.ID) req, _ = http.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/agent_capacity_policies/%d/users", s.account.ID, policyID), bytes.NewBufferString(body)) @@ -200,6 +223,15 @@ func (s *AgentCapacityHandlerTestSuite) TestChatwootPolicyInboxLimitAndUserFlow( s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &assigned)) s.Require().Equal(float64(user.ID), assigned["id"]) + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/agent_capacity_policies/%d/users", s.account.ID, policyID), nil) + r.ServeHTTP(w, req) + s.Require().Equal(http.StatusOK, w.Code) + var assignedUsers []map[string]any + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &assignedUsers)) + s.Require().Len(assignedUsers, 1) + s.Require().Equal(float64(user.ID), assignedUsers[0]["id"]) + w = httptest.NewRecorder() req, _ = http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/agent_capacity_policies/%d", s.account.ID, policyID), nil) r.ServeHTTP(w, req) @@ -212,6 +244,65 @@ func (s *AgentCapacityHandlerTestSuite) TestChatwootPolicyInboxLimitAndUserFlow( req, _ = http.NewRequest(http.MethodDelete, fmt.Sprintf("/api/v1/accounts/%d/agent_capacity_policies/%d/inbox_limits/%d", s.account.ID, policyID, limitID), nil) r.ServeHTTP(w, req) s.Require().Equal(http.StatusNoContent, w.Code) + + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodDelete, fmt.Sprintf("/api/v1/accounts/%d/agent_capacity_policies/%d/users/%d", s.account.ID, policyID, user.ID), nil) + r.ServeHTTP(w, req) + s.Require().Equal(http.StatusOK, w.Code) + + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/agent_capacity_policies/%d/users", s.account.ID, policyID), nil) + r.ServeHTTP(w, req) + s.Require().Equal(http.StatusOK, w.Code) + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &assignedUsers)) + s.Require().Empty(assignedUsers) +} + +func (s *AgentCapacityHandlerTestSuite) TestChatwootPolicyInboxLimitAndUserValidationErrors() { + r := gin.New() + api := r.Group("/api/v1/accounts/:account_id") + RegisterAgentCapacityRoutes(api, s.handler) + + inbox := &model.Inbox{AccountID: s.account.ID, Name: "Validation", ChannelType: "web_widget"} + s.Require().NoError(s.db.Create(inbox).Error) + + w := httptest.NewRecorder() + body := `{"agent_capacity_policy":{"name":"Validation policy"}}` + req, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/agent_capacity_policies", s.account.ID), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + s.Require().Equal(http.StatusOK, w.Code, w.Body.String()) + var policy map[string]any + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &policy)) + policyID := uint(policy["id"].(float64)) + + w = httptest.NewRecorder() + body = fmt.Sprintf(`{"inbox_id":%d,"conversation_limit":-1}`, inbox.ID) + req, _ = http.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/agent_capacity_policies/%d/inbox_limits", s.account.ID, policyID), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + s.Require().Equal(http.StatusUnprocessableEntity, w.Code, w.Body.String()) + assertErrorEnvelope(s.T(), w.Body.Bytes(), "VALIDATION_ERROR", "conversation_limit must be greater than or equal") + + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodPatch, fmt.Sprintf("/api/v1/accounts/%d/agent_capacity_policies/%d/inbox_limits/999999", s.account.ID, policyID), bytes.NewBufferString(`{"conversation_limit":5}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + s.Require().Equal(http.StatusNotFound, w.Code, w.Body.String()) + assertErrorEnvelope(s.T(), w.Body.Bytes(), "NOT_FOUND", "inbox capacity limit not found") + + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/agent_capacity_policies/%d/users", s.account.ID, policyID), bytes.NewBufferString(`{"user_id":0}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + s.Require().Equal(http.StatusBadRequest, w.Code, w.Body.String()) + assertErrorEnvelope(s.T(), w.Body.Bytes(), "VALIDATION_ERROR", "user_id is required") + + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodDelete, fmt.Sprintf("/api/v1/accounts/%d/agent_capacity_policies/%d/users/999999", s.account.ID, policyID), nil) + r.ServeHTTP(w, req) + s.Require().Equal(http.StatusNotFound, w.Code, w.Body.String()) + assertErrorEnvelope(s.T(), w.Body.Bytes(), "NOT_FOUND", "account user not found") } func (s *AgentCapacityHandlerTestSuite) TestMutations_WriteAuditEntries() { diff --git a/internal/handler/api/v1/agent_handler_test.go b/internal/handler/api/v1/agent_handler_test.go index 12181d45..a64e52e6 100644 --- a/internal/handler/api/v1/agent_handler_test.go +++ b/internal/handler/api/v1/agent_handler_test.go @@ -192,6 +192,32 @@ func (s *AgentHandlerTestSuite) TestCreateAgent() { assert.Equal(s.T(), s.user.ID, membership.InvitedBy) } +func (s *AgentHandlerTestSuite) TestCreateAgentChatwootFrontendPayload() { + req := service.CreateAgentRequest{ + Email: "chatwoot-agent@test.com", + Name: "Chatwoot Agent", + Role: "administrator", + } + w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID) + s.handler.Create(c) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var data map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &data)) + assert.Equal(s.T(), "chatwoot-agent@test.com", data["email"]) + assert.Equal(s.T(), "Chatwoot Agent", data["name"]) + assert.Equal(s.T(), "Chatwoot Agent", data["available_name"]) + assert.Equal(s.T(), "administrator", data["role"]) + assert.Equal(s.T(), "offline", data["availability_status"]) + assert.IsType(s.T(), true, data["auto_offline"]) + assert.Equal(s.T(), "email", data["provider"]) + assert.Contains(s.T(), data, "account_id") + assert.Contains(s.T(), data, "confirmed") + assert.Contains(s.T(), data, "thumbnail") + assert.NotContains(s.T(), data, "payload") + assert.NotContains(s.T(), data, "data") +} + func (s *AgentHandlerTestSuite) TestCreateAgentSendsWorkspaceInvitation() { req := service.CreateAgentRequest{Email: "invite-mail@test.com", Name: "Invite Mail", Role: "agent"} w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID) diff --git a/internal/handler/api/v1/analytics_handler_test.go b/internal/handler/api/v1/analytics_handler_test.go index ce1715b0..983ff23c 100644 --- a/internal/handler/api/v1/analytics_handler_test.go +++ b/internal/handler/api/v1/analytics_handler_test.go @@ -77,6 +77,22 @@ func (s *AnalyticsHandlerTestSuite) SetupSuite() { accounts.GET("/reports/first_response_time_distribution", s.handler.FirstResponseTimeDistribution) accounts.GET("/reports/inbox_label_matrix", s.handler.InboxLabelMatrix) accounts.GET("/reports/outgoing_messages_count", s.handler.OutgoingMessagesCount) + v2Accounts := r.Group("/api/v2/accounts/:account_id") + v2Reports := v2Accounts.Group("/reports") + v2Reports.GET("", s.handler.Index) + v2Reports.GET("/summary", s.handler.Summary) + v2Reports.GET("/agents", s.handler.AgentMetrics) + v2Reports.GET("/inboxes", s.handler.InboxMetrics) + v2Reports.GET("/labels", s.handler.LabelMetrics) + v2Reports.GET("/teams", s.handler.TeamMetrics) + v2Reports.GET("/conversations", s.handler.Conversations) + v2Reports.GET("/conversations_summary", s.handler.ConversationsSummary) + v2Reports.GET("/conversation_traffic", s.handler.ConversationTraffic) + v2Reports.GET("/bot_summary", s.handler.BotSummary) + v2Reports.GET("/bot_metrics", s.handler.BotMetrics) + v2Reports.GET("/inbox_label_matrix", s.handler.InboxLabelMatrix) + v2Reports.GET("/first_response_time_distribution", s.handler.FirstResponseTimeDistribution) + v2Reports.GET("/outgoing_messages_count", s.handler.OutgoingMessagesCount) s.router = r } @@ -223,6 +239,212 @@ func (s *AnalyticsHandlerTestSuite) TestIndex_TimeseriesHonorsTimezoneOffset() { s.Equal(float64(1), data[1]["value"]) } +func (s *AnalyticsHandlerTestSuite) TestAPIV2Reports_TimeseriesTimezoneValueParity() { + inbox := model.Inbox{AccountID: s.accountID, Name: "Timezone Web", ChannelType: "web_widget", ChannelID: 1, Enabled: true} + s.Require().NoError(s.db.Create(&inbox).Error) + first := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: parseTime("2025-01-02T01:00:00Z")}} + second := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: 2, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: parseTime("2025-01-02T09:30:00Z")}} + s.Require().NoError(s.db.Create(&first).Error) + s.Require().NoError(s.db.Create(&second).Error) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, + "/api/v2/accounts/1/reports?metric=conversations_count&since=1735689600&until=1735862400&type=inbox&id="+strconv.FormatUint(uint64(inbox.ID), 10)+"&group_by=day&timezone_offset=-8&business_hours=false", nil) + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code) + var data []map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &data)) + s.Len(data, 3) + loc := time.FixedZone("report", -8*3600) + s.Equal(float64(time.Date(2024, 12, 31, 0, 0, 0, 0, loc).Unix()), data[0]["timestamp"]) + s.Equal(float64(0), data[0]["value"]) + s.Equal(float64(time.Date(2025, 1, 1, 0, 0, 0, 0, loc).Unix()), data[1]["timestamp"]) + s.Equal(float64(1), data[1]["value"]) + s.Equal(float64(time.Date(2025, 1, 2, 0, 0, 0, 0, loc).Unix()), data[2]["timestamp"]) + s.Equal(float64(1), data[2]["value"]) +} + +func (s *AnalyticsHandlerTestSuite) TestAPIV2Reports_ChatwootPayloadShapes() { + user := model.User{AccountID: s.accountID, Name: "Ada Agent", Email: "ada@example.com", Password: "secret"} + s.Require().NoError(s.db.Create(&user).Error) + s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: s.accountID, UserID: user.ID, Role: "agent", Availability: "online"}).Error) + inbox := model.Inbox{AccountID: s.accountID, Name: "Web", ChannelType: "web_widget", ChannelID: 1, Enabled: true} + s.Require().NoError(s.db.Create(&inbox).Error) + resolvedAt := parseTime("2025-01-16T10:00:00Z") + conversation := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: 1, AssigneeID: &user.ID, Status: string(model.ConversationStatusResolved), ChannelType: "web_widget", Channel: "web_widget", ResolvedAt: &resolvedAt, Base: model.Base{CreatedAt: parseTime("2025-01-15T10:00:00Z")}} + s.Require().NoError(s.db.Create(&conversation).Error) + s.Require().NoError(s.db.Create(&model.Message{AccountID: s.accountID, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: string(model.MessageTypeIncoming), Content: "hello", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:01:00Z")}}).Error) + s.Require().NoError(s.db.Create(&model.Message{AccountID: s.accountID, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: string(model.MessageTypeOutgoing), Content: "reply", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:02:00Z")}}).Error) + s.seedReportingEvent(model.MetricNameFirstResponse, 120, user.ID, &inbox.ID, conversation.ID, "2025-01-15T10:05:00Z") + + summary := httptest.NewRecorder() + s.router.ServeHTTP(summary, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/reports/summary?since=1735689600&until=1738368000&type=account&timezone_offset=-8", nil)) + s.Equal(http.StatusOK, summary.Code) + var summaryPayload map[string]interface{} + s.Require().NoError(json.Unmarshal(summary.Body.Bytes(), &summaryPayload)) + assertChatwootSummaryReportShape(s.T(), summaryPayload) + s.Equal(float64(1), summaryPayload["conversations_count"]) + s.Equal(float64(1), summaryPayload["incoming_messages_count"]) + s.Equal(float64(1), summaryPayload["outgoing_messages_count"]) + + timeseries := httptest.NewRecorder() + s.router.ServeHTTP(timeseries, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/reports?metric=conversations_count&since=1735689600&until=1738368000&type=account&group_by=day&timezone_offset=-8", nil)) + s.Equal(http.StatusOK, timeseries.Code) + var timeseriesPayload []map[string]interface{} + s.Require().NoError(json.Unmarshal(timeseries.Body.Bytes(), ×eriesPayload)) + s.Require().NotEmpty(timeseriesPayload) + assertChatwootTimeseriesPointShape(s.T(), timeseriesPayload[0]) + + agentsCSV := httptest.NewRecorder() + s.router.ServeHTTP(agentsCSV, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/reports/agents?since=1735689600&until=1738368000", nil)) + s.Equal(http.StatusOK, agentsCSV.Code) + assertChatwootReportCSVShape(s.T(), agentsCSV, []string{"Agent name", "Assigned conversations", "Avg first response time", "Avg resolution time", "Avg customer waiting time", "Resolution Count"}) +} + +func (s *AnalyticsHandlerTestSuite) TestAPIV2Reports_AllFrontendEndpointShapes() { + user := model.User{AccountID: s.accountID, Name: "Ada Agent", Email: "ada@example.com", Password: "secret"} + s.Require().NoError(s.db.Create(&user).Error) + s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: s.accountID, UserID: user.ID, Role: "agent", Availability: "online"}).Error) + inbox := model.Inbox{AccountID: s.accountID, Name: "Web", ChannelType: "web_widget", ChannelID: 1, Enabled: true} + s.Require().NoError(s.db.Create(&inbox).Error) + team := model.Team{AccountID: s.accountID, Name: "Support", Description: "Support team", AllowAutoAssignment: true} + s.Require().NoError(s.db.Create(&team).Error) + label := model.Tag{AccountID: s.accountID, Name: "vip"} + s.Require().NoError(s.db.Create(&label).Error) + resolvedAt := parseTime("2025-01-16T10:00:00Z") + conversation := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: 1, AssigneeID: &user.ID, Status: string(model.ConversationStatusResolved), ChannelType: "web_widget", Channel: "web_widget", ResolvedAt: &resolvedAt, Base: model.Base{CreatedAt: parseTime("2025-01-15T10:00:00Z")}} + s.Require().NoError(s.db.Create(&conversation).Error) + s.Require().NoError(s.db.Create(&model.ConversationLabel{AccountID: s.accountID, ConversationID: conversation.ID, TagID: label.ID}).Error) + s.Require().NoError(s.db.Create(&model.Message{AccountID: s.accountID, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: string(model.MessageTypeIncoming), Content: "hello", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:01:00Z")}}).Error) + s.Require().NoError(s.db.Create(&model.Message{AccountID: s.accountID, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: string(model.MessageTypeOutgoing), SenderType: "User", Content: "reply", SenderID: &user.ID, Base: model.Base{CreatedAt: parseTime("2025-01-15T10:02:00Z")}}).Error) + s.seedReportingEvent(model.MetricNameFirstResponse, 120, user.ID, &inbox.ID, conversation.ID, "2025-01-15T10:05:00Z") + s.seedReportingEvent(model.MetricNameReplyTime, 45, user.ID, &inbox.ID, conversation.ID, "2025-01-15T10:06:00Z") + s.seedReportingEvent(model.MetricNameResolutionTime, 3600, user.ID, &inbox.ID, conversation.ID, "2025-01-16T10:00:00Z") + + baseQuery := "since=1735689600&until=1738368000" + csvEndpoints := []struct { + path string + headers []string + }{ + {"/api/v2/accounts/1/reports/inboxes?" + baseQuery, []string{"Inbox name", "Inbox type", "No. of conversations", "Avg first response time", "Avg resolution time"}}, + {"/api/v2/accounts/1/reports/labels?" + baseQuery, []string{"Label", "No. of conversations", "Avg first response time", "Avg resolution time", "Avg reply time", "Resolution Count"}}, + {"/api/v2/accounts/1/reports/teams?" + baseQuery, []string{"Team name", "Conversations count", "Avg first response time", "Avg resolution time", "Avg customer waiting time", "Resolution Count"}}, + {"/api/v2/accounts/1/reports/conversations_summary?" + baseQuery, []string{"Conversations", "Messages received", "Messages sent", "Avg first response time", "Avg resolution time", "Resolution count", "Avg customer waiting time"}}, + } + for _, endpoint := range csvEndpoints { + recorder := httptest.NewRecorder() + s.router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, endpoint.path, nil)) + s.Equal(http.StatusOK, recorder.Code, endpoint.path) + assertChatwootReportCSVShape(s.T(), recorder, endpoint.headers) + } + + conversationTraffic := httptest.NewRecorder() + s.router.ServeHTTP(conversationTraffic, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/reports/conversation_traffic?"+baseQuery+"&timezone_offset=-8", nil)) + s.Equal(http.StatusOK, conversationTraffic.Code) + assertChatwootConversationTrafficCSVShape(s.T(), conversationTraffic) + + conversations := httptest.NewRecorder() + s.router.ServeHTTP(conversations, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/reports/conversations?type=agent&page=1", nil)) + s.Equal(http.StatusOK, conversations.Code) + conversationPayload := decodeJSONArray(s.T(), conversations.Body.String()) + s.Require().NotEmpty(conversationPayload) + assertChatwootConversationReportShape(s.T(), conversationPayload[0]) + + botMetrics := httptest.NewRecorder() + s.router.ServeHTTP(botMetrics, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/reports/bot_metrics?"+baseQuery, nil)) + s.Equal(http.StatusOK, botMetrics.Code) + assertChatwootJSONObject(s.T(), botMetrics.Body.String(), []string{"conversation_count", "message_count", "resolution_rate", "handoff_rate"}) + + botSummary := httptest.NewRecorder() + s.router.ServeHTTP(botSummary, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/reports/bot_summary?"+baseQuery+"&type=account&group_by=day&business_hours=false", nil)) + s.Equal(http.StatusOK, botSummary.Code) + assertChatwootJSONObject(s.T(), botSummary.Body.String(), []string{"bot_resolutions_count", "bot_handoffs_count", "previous"}) + + matrix := httptest.NewRecorder() + s.router.ServeHTTP(matrix, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/reports/inbox_label_matrix?"+baseQuery+"&inbox_ids[]="+strconv.FormatUint(uint64(inbox.ID), 10)+"&label_ids="+strconv.FormatUint(uint64(label.ID), 10), nil)) + s.Equal(http.StatusOK, matrix.Code) + assertChatwootJSONObject(s.T(), matrix.Body.String(), []string{"matrix", "inboxes", "labels"}) + + distribution := httptest.NewRecorder() + s.router.ServeHTTP(distribution, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/reports/first_response_time_distribution?"+baseQuery, nil)) + s.Equal(http.StatusOK, distribution.Code) + assertChatwootJSONObject(s.T(), distribution.Body.String(), []string{"web_widget"}) + + outgoing := httptest.NewRecorder() + s.router.ServeHTTP(outgoing, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/reports/outgoing_messages_count?"+baseQuery+"&group_by=agent", nil)) + s.Equal(http.StatusOK, outgoing.Code) + outgoingPayload := decodeJSONArray(s.T(), outgoing.Body.String()) + s.Require().NotEmpty(outgoingPayload) + assertChatwootOutgoingMessagesCountShape(s.T(), outgoingPayload[0]) +} + +func (s *AnalyticsHandlerTestSuite) TestAPIV2Reports_OutgoingMessagesCountValueParity() { + agent := model.User{AccountID: s.accountID, Name: "Ada Agent", Email: "ada-value@example.com", Password: "secret"} + agent2 := model.User{AccountID: s.accountID, Name: "Ben Agent", Email: "ben-value@example.com", Password: "secret"} + s.Require().NoError(s.db.Create(&agent).Error) + s.Require().NoError(s.db.Create(&agent2).Error) + s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: s.accountID, UserID: agent.ID, Role: "agent", Availability: "online"}).Error) + s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: s.accountID, UserID: agent2.ID, Role: "agent", Availability: "online"}).Error) + + inbox := model.Inbox{AccountID: s.accountID, Name: "Web", ChannelType: "web_widget", ChannelID: 1, Enabled: true} + inbox2 := model.Inbox{AccountID: s.accountID, Name: "Email", ChannelType: "email", ChannelID: 2, Enabled: true} + s.Require().NoError(s.db.Create(&inbox).Error) + s.Require().NoError(s.db.Create(&inbox2).Error) + team := model.Team{AccountID: s.accountID, Name: "Support", Description: "Support team", AllowAutoAssignment: true} + s.Require().NoError(s.db.Create(&team).Error) + label := model.Tag{AccountID: s.accountID, Name: "support"} + s.Require().NoError(s.db.Create(&label).Error) + + convAgent := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: 1, AssigneeID: &agent.ID, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:00:00Z")}} + convAgent2 := model.Conversation{AccountID: s.accountID, InboxID: inbox2.ID, ContactID: 2, AssigneeID: &agent2.ID, Status: string(model.ConversationStatusOpen), ChannelType: "email", Channel: "email", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:00:00Z")}} + convTeam := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: 3, TeamID: &team.ID, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:00:00Z")}} + s.Require().NoError(s.db.Create(&convAgent).Error) + s.Require().NoError(s.db.Create(&convAgent2).Error) + s.Require().NoError(s.db.Create(&convTeam).Error) + s.Require().NoError(s.db.Create(&model.ConversationLabel{AccountID: s.accountID, ConversationID: convAgent.ID, TagID: label.ID}).Error) + + s.seedReportMessage(inbox.ID, convAgent.ID, model.MessageTypeOutgoing, "User", &agent.ID, "2025-01-15T10:01:00Z") + s.seedReportMessage(inbox.ID, convAgent.ID, model.MessageTypeOutgoing, "User", &agent.ID, "2025-01-15T10:02:00Z") + s.seedReportMessage(inbox.ID, convAgent.ID, model.MessageTypeOutgoing, "User", &agent.ID, "2025-01-15T10:03:00Z") + s.seedReportMessage(inbox.ID, convAgent.ID, model.MessageTypeIncoming, "Contact", nil, "2025-01-15T10:04:00Z") + s.seedReportMessage(inbox2.ID, convAgent2.ID, model.MessageTypeOutgoing, "User", &agent2.ID, "2025-01-15T10:05:00Z") + s.seedReportMessage(inbox2.ID, convAgent2.ID, model.MessageTypeOutgoing, "User", &agent2.ID, "2025-01-15T10:06:00Z") + s.seedReportMessage(inbox.ID, convTeam.ID, model.MessageTypeOutgoing, "User", nil, "2025-01-15T10:07:00Z") + s.seedReportMessage(inbox.ID, convTeam.ID, model.MessageTypeOutgoing, "User", nil, "2025-01-15T10:08:00Z") + s.seedReportMessage(inbox.ID, convTeam.ID, model.MessageTypeOutgoing, "User", nil, "2025-01-15T10:09:00Z") + s.seedReportMessage(inbox.ID, convTeam.ID, model.MessageTypeOutgoing, "User", nil, "2025-01-15T10:10:00Z") + s.seedReportMessage(inbox.ID, convAgent.ID, model.MessageTypeOutgoing, "AgentBot", nil, "2025-01-15T10:11:00Z") + s.seedReportMessage(inbox.ID, convAgent.ID, model.MessageTypeOutgoing, "User", &agent.ID, "2025-02-02T10:01:00Z") + + basePath := "/api/v2/accounts/1/reports/outgoing_messages_count?since=1735689600&until=1738368000" + agents := s.requestOutgoingCount(basePath + "&group_by=agent") + s.Equal(float64(3), findReportRowByID(s.T(), agents, agent.ID)["outgoing_messages_count"]) + s.Equal(float64(2), findReportRowByID(s.T(), agents, agent2.ID)["outgoing_messages_count"]) + s.Nil(findReportRowByName(agents, "AgentBot")) + + teams := s.requestOutgoingCount(basePath + "&group_by=team") + s.Len(teams, 1) + s.Equal(float64(team.ID), teams[0]["id"]) + s.Equal("Support", teams[0]["name"]) + s.Equal(float64(4), teams[0]["outgoing_messages_count"]) + + inboxes := s.requestOutgoingCount(basePath + "&group_by=inbox") + s.Equal(float64(8), findReportRowByID(s.T(), inboxes, inbox.ID)["outgoing_messages_count"]) + s.Equal(float64(2), findReportRowByID(s.T(), inboxes, inbox2.ID)["outgoing_messages_count"]) + + labels := s.requestOutgoingCount(basePath + "&group_by=label") + s.Len(labels, 1) + s.Equal(float64(label.ID), labels[0]["id"]) + s.Equal("support", labels[0]["name"]) + s.Equal(float64(4), labels[0]["outgoing_messages_count"]) + + invalid := httptest.NewRecorder() + s.router.ServeHTTP(invalid, httptest.NewRequest(http.MethodGet, basePath+"&group_by=invalid", nil)) + s.Equal(http.StatusUnprocessableEntity, invalid.Code) + s.Empty(strings.TrimSpace(invalid.Body.String())) +} + // ========== AgentMetrics ========== func (s *AnalyticsHandlerTestSuite) TestAgentMetrics_InvalidAccountID() { @@ -266,6 +488,119 @@ func (s *AnalyticsHandlerTestSuite) TestAgentMetrics_ReturnsChatwootCSVDownload( s.Equal([]string{"Ada Agent", "1", "2 minutes", "1 hour", "45 seconds", "1"}, rows[2]) } +func (s *AnalyticsHandlerTestSuite) TestAPIV2Reports_CSVDownloadEntrypointsMatchChatwootFrontend() { + paths := []struct { + name string + path string + filename string + headers []string + seedFixture func() + }{ + { + name: "agents", + path: "/api/v2/accounts/1/reports/agents?since=1735689600&until=1738368000&business_hours=false", + filename: "agents_report.csv", + headers: []string{ + "Agent name", + "Assigned conversations", + "Avg first response time", + "Avg resolution time", + "Avg customer waiting time", + "Resolution Count", + }, + seedFixture: func() { + user := model.User{AccountID: s.accountID, Name: "CSV Agent", Email: "csv-agent@example.com", Password: "secret"} + s.Require().NoError(s.db.Create(&user).Error) + s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: s.accountID, UserID: user.ID, Role: "agent"}).Error) + }, + }, + { + name: "inboxes", + path: "/api/v2/accounts/1/reports/inboxes?since=1735689600&until=1738368000&business_hours=false", + filename: "inboxes_report.csv", + headers: []string{ + "Inbox name", + "Inbox type", + "No. of conversations", + "Avg first response time", + "Avg resolution time", + }, + seedFixture: func() { + inbox := model.Inbox{AccountID: s.accountID, Name: "CSV Inbox", ChannelType: "web_widget", ChannelID: 1, Enabled: true} + s.Require().NoError(s.db.Create(&inbox).Error) + }, + }, + { + name: "labels", + path: "/api/v2/accounts/1/reports/labels?since=1735689600&until=1738368000&business_hours=false", + filename: "labels_report.csv", + headers: []string{ + "Label", + "No. of conversations", + "Avg first response time", + "Avg resolution time", + "Avg reply time", + "Resolution Count", + }, + seedFixture: func() { + label := model.Tag{AccountID: s.accountID, Name: "csv-label"} + s.Require().NoError(s.db.Create(&label).Error) + }, + }, + { + name: "teams", + path: "/api/v2/accounts/1/reports/teams?since=1735689600&until=1738368000&business_hours=false", + filename: "teams_report.csv", + headers: []string{ + "Team name", + "Conversations count", + "Avg first response time", + "Avg resolution time", + "Avg customer waiting time", + "Resolution Count", + }, + seedFixture: func() { + team := model.Team{AccountID: s.accountID, Name: "CSV Team"} + s.Require().NoError(s.db.Create(&team).Error) + }, + }, + { + name: "conversations_summary", + path: "/api/v2/accounts/1/reports/conversations_summary?since=1735689600&until=1738368000&business_hours=false", + filename: "conversations_summary_report.csv", + headers: []string{ + "Conversations", + "Messages received", + "Messages sent", + "Avg first response time", + "Avg resolution time", + "Resolution count", + "Avg customer waiting time", + }, + }, + } + + for _, tt := range paths { + s.Run(tt.name, func() { + s.SetupTest() + if tt.seedFixture != nil { + tt.seedFixture() + } + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, tt.path, nil) + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code, tt.name) + s.Equal("text/csv", w.Header().Get("Content-Type"), tt.name) + s.Equal("attachment; filename="+tt.filename, w.Header().Get("Content-Disposition"), tt.name) + s.NotContains(w.Body.String(), "\"success\"", tt.name) + s.NotContains(w.Body.String(), "\"payload\"", tt.name) + assertChatwootReportCSVShape(s.T(), w, tt.headers) + }) + } +} + // ========== InboxMetrics ========== func (s *AnalyticsHandlerTestSuite) TestInboxMetrics_InvalidAccountID() { @@ -560,6 +895,52 @@ func (s *AnalyticsHandlerTestSuite) seedReportingEvent(name string, value float6 s.Require().NoError(s.db.Create(&event).Error) } +func (s *AnalyticsHandlerTestSuite) seedReportMessage(inboxID, conversationID uint, messageType model.MessageType, senderType string, senderID *uint, createdAt string) { + message := model.Message{ + AccountID: s.accountID, + InboxID: inboxID, + ConversationID: conversationID, + MessageType: string(messageType), + SenderType: senderType, + SenderID: senderID, + Content: "fixture message", + Base: model.Base{CreatedAt: parseTime(createdAt)}, + } + s.Require().NoError(s.db.Create(&message).Error) +} + +func (s *AnalyticsHandlerTestSuite) requestOutgoingCount(path string) []map[string]interface{} { + recorder := httptest.NewRecorder() + s.router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil)) + s.Require().Equal(http.StatusOK, recorder.Code, path) + payload := decodeJSONArray(s.T(), recorder.Body.String()) + for _, row := range payload { + assertChatwootOutgoingMessagesCountShape(s.T(), row) + } + return payload +} + +func findReportRowByID(t *testing.T, rows []map[string]interface{}, id uint) map[string]interface{} { + t.Helper() + expectedID := float64(id) + for _, row := range rows { + if row["id"] == expectedID { + return row + } + } + t.Fatalf("expected report row with id %d, got %#v", id, rows) + return nil +} + +func findReportRowByName(rows []map[string]interface{}, name string) map[string]interface{} { + for _, row := range rows { + if row["name"] == name { + return row + } + } + return nil +} + func readCSVRows(t *testing.T, body string) [][]string { t.Helper() reader := csv.NewReader(strings.NewReader(body)) @@ -570,3 +951,125 @@ func readCSVRows(t *testing.T, body string) [][]string { } return rows } + +func assertChatwootSummaryReportShape(t *testing.T, payload map[string]interface{}) { + t.Helper() + requiredKeys := []string{ + "conversations_count", + "incoming_messages_count", + "outgoing_messages_count", + "avg_first_response_time", + "avg_resolution_time", + "resolutions_count", + "reply_time", + "previous", + } + for _, key := range requiredKeys { + if _, ok := payload[key]; !ok { + t.Fatalf("expected summary report payload to include %q, got %#v", key, payload) + } + } +} + +func assertChatwootTimeseriesPointShape(t *testing.T, payload map[string]interface{}) { + t.Helper() + if _, ok := payload["timestamp"]; !ok { + t.Fatalf("expected timeseries point to include timestamp, got %#v", payload) + } + if _, ok := payload["value"]; !ok { + t.Fatalf("expected timeseries point to include value, got %#v", payload) + } +} + +func assertChatwootReportCSVShape(t *testing.T, recorder *httptest.ResponseRecorder, expectedHeaders []string) { + t.Helper() + if contentType := recorder.Header().Get("Content-Type"); !strings.Contains(contentType, "text/csv") { + t.Fatalf("expected CSV content type, got %q", contentType) + } + if disposition := recorder.Header().Get("Content-Disposition"); !strings.Contains(disposition, ".csv") { + t.Fatalf("expected CSV attachment disposition, got %q", disposition) + } + rows := readCSVRows(t, recorder.Body.String()) + if len(rows) < 2 { + t.Fatalf("expected report period and header rows, got %#v", rows) + } + if len(rows[0]) != 1 || !strings.HasPrefix(rows[0][0], "Reporting period ") { + t.Fatalf("expected Chatwoot reporting period row, got %#v", rows[0]) + } + headerRow := rows[1] + if len(headerRow) == 0 && len(rows) > 2 { + headerRow = rows[2] + } + if len(headerRow) != len(expectedHeaders) { + t.Fatalf("expected CSV headers %#v, got %#v", expectedHeaders, headerRow) + } + for idx, expected := range expectedHeaders { + if headerRow[idx] != expected { + t.Fatalf("expected CSV headers %#v, got %#v", expectedHeaders, headerRow) + } + } +} + +func assertChatwootConversationTrafficCSVShape(t *testing.T, recorder *httptest.ResponseRecorder) { + t.Helper() + if contentType := recorder.Header().Get("Content-Type"); !strings.Contains(contentType, "text/csv") { + t.Fatalf("expected CSV content type, got %q", contentType) + } + if disposition := recorder.Header().Get("Content-Disposition"); !strings.Contains(disposition, "conversation_traffic_reports.csv") { + t.Fatalf("expected conversation traffic CSV attachment, got %q", disposition) + } + rows := readCSVRows(t, recorder.Body.String()) + if len(rows) < 2 || len(rows[0]) != 2 || rows[0][0] != "Timezone" { + t.Fatalf("expected Chatwoot conversation traffic timezone row, got %#v", rows) + } +} + +func assertChatwootJSONObject(t *testing.T, body string, requiredKeys []string) map[string]interface{} { + t.Helper() + payload := map[string]interface{}{} + if err := json.Unmarshal([]byte(body), &payload); err != nil { + t.Fatalf("expected JSON object: %v\n%s", err, body) + } + for _, key := range requiredKeys { + if _, ok := payload[key]; !ok { + t.Fatalf("expected JSON object to include %q, got %#v", key, payload) + } + } + return payload +} + +func decodeJSONArray(t *testing.T, body string) []map[string]interface{} { + t.Helper() + payload := []map[string]interface{}{} + if err := json.Unmarshal([]byte(body), &payload); err != nil { + t.Fatalf("expected JSON array: %v\n%s", err, body) + } + return payload +} + +func assertChatwootConversationReportShape(t *testing.T, payload map[string]interface{}) { + t.Helper() + for _, key := range []string{"id", "name", "email", "thumbnail", "availability", "metric"} { + if _, ok := payload[key]; !ok { + t.Fatalf("expected conversation report row to include %q, got %#v", key, payload) + } + } + metric, ok := payload["metric"].(map[string]interface{}) + if !ok { + t.Fatalf("expected conversation report metric object, got %#v", payload["metric"]) + } + for _, key := range []string{"open", "unattended"} { + if _, ok := metric[key]; !ok { + t.Fatalf("expected conversation report metric to include %q, got %#v", key, metric) + } + } +} + +func assertChatwootOutgoingMessagesCountShape(t *testing.T, payload map[string]interface{}) { + t.Helper() + for _, key := range []string{"id", "name", "outgoing_messages_count"} { + if _, ok := payload[key]; !ok { + t.Fatalf("expected outgoing messages count row to include %q, got %#v", key, payload) + } + } +} diff --git a/internal/handler/api/v1/article_handler.go b/internal/handler/api/v1/article_handler.go index 1e11fac1..4005a773 100644 --- a/internal/handler/api/v1/article_handler.go +++ b/internal/handler/api/v1/article_handler.go @@ -50,9 +50,11 @@ func (h *ArticleHandler) PublicList(c *gin.Context) { } countParams := repository.ArticleSearchParams{ - PortalID: portal.ID, - Locale: locale, - Status: string(model.ArticleStatusPublished), + PortalID: portal.ID, + Query: query, + CategorySlug: c.Param("category_slug"), + Locale: locale, + Status: string(model.ArticleStatusPublished), } articlesCount, err := h.svc.Count(c.Request.Context(), countParams) if err != nil { diff --git a/internal/handler/api/v1/article_handler_test.go b/internal/handler/api/v1/article_handler_test.go index 0d0b612e..31ed384b 100644 --- a/internal/handler/api/v1/article_handler_test.go +++ b/internal/handler/api/v1/article_handler_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + imagepng "image/png" "net/http" "net/http/httptest" "testing" @@ -62,10 +63,35 @@ func (s *ArticleHandlerTestSuite) TearDownSuite() { } } +func (s *ArticleHandlerTestSuite) SetupTest() { + s.Require().NoError(s.db.Exec("DELETE FROM background_jobs").Error) + s.Require().NoError(s.db.Exec("DELETE FROM articles").Error) + s.Require().NoError(s.db.Exec("DELETE FROM categories").Error) + s.Require().NoError(s.db.Exec("DELETE FROM folders").Error) + s.Require().NoError(s.db.Exec("DELETE FROM portal_members").Error) + s.Require().NoError(s.db.Exec("DELETE FROM portals").Error) + s.Require().NoError(s.db.Exec("DELETE FROM users").Error) + s.portal = &model.Portal{AccountID: s.account.ID, Name: "test-portal", Slug: "test-portal"} + s.Require().NoError(s.db.Create(s.portal).Error) +} + func TestArticleHandlerSuite(t *testing.T) { suite.Run(t, new(ArticleHandlerTestSuite)) } +func assertDashboardArticlePayload(t *testing.T, payload map[string]interface{}, expected map[string]interface{}) { + t.Helper() + for key, value := range expected { + assert.Equal(t, value, payload[key], "article payload field %s", key) + } + for _, key := range []string{"id", "slug", "title", "content", "description", "status", "position", "account_id", "updated_at", "meta", "category", "views", "associated_articles"} { + assert.Contains(t, payload, key) + } + assert.IsType(t, map[string]interface{}{}, payload["meta"]) + assert.IsType(t, map[string]interface{}{}, payload["category"]) + assert.IsType(t, []interface{}{}, payload["associated_articles"]) +} + func (s *ArticleHandlerTestSuite) TestCreate_BadRequest_InvalidAccountID() { r := gin.New() r.POST("/api/v1/accounts/:account_id/portals/:portal_id/articles", s.handler.Create) @@ -273,6 +299,7 @@ func (s *ArticleHandlerTestSuite) TestPublicMarkdown_ReturnsOnlyPublishedMarkdow portal := &model.Portal{AccountID: s.account.ID, Name: "Markdown Portal", Slug: "markdown-portal"} s.Require().NoError(s.db.Create(portal).Error) s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, Title: "Markdown", Slug: "markdown", Content: "# Raw markdown", Status: "published", Locale: "en"}).Error) + s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, Title: "Markdown FR", Slug: "markdown-fr", Content: "# Contenu français", Status: "published", Locale: "fr"}).Error) s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, Title: "Draft", Slug: "markdown-draft", Content: "draft", Status: "draft", Locale: "en"}).Error) r := gin.New() @@ -286,6 +313,14 @@ func (s *ArticleHandlerTestSuite) TestPublicMarkdown_ReturnsOnlyPublishedMarkdow assert.Equal(s.T(), "text/markdown; charset=utf-8", w.Header().Get("Content-Type")) assert.Equal(s.T(), "# Raw markdown", w.Body.String()) + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/hc/markdown-portal/articles/markdown-fr.md", nil) + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + assert.Equal(s.T(), "text/markdown; charset=utf-8", w.Header().Get("Content-Type")) + assert.Equal(s.T(), "# Contenu français", w.Body.String()) + w = httptest.NewRecorder() req, _ = http.NewRequest("GET", "/hc/markdown-portal/articles/markdown-draft.md", nil) r.ServeHTTP(w, req) @@ -298,6 +333,8 @@ func (s *ArticleHandlerTestSuite) TestPublicTrackingPixel_IncrementsPublishedArt s.Require().NoError(s.db.Create(portal).Error) article := &model.Article{AccountID: s.account.ID, PortalID: portal.ID, Title: "Pixel", Slug: "pixel", Content: "pixel", Status: "published", Locale: "en", Views: 3} s.Require().NoError(s.db.Create(article).Error) + draft := &model.Article{AccountID: s.account.ID, PortalID: portal.ID, Title: "Pixel Draft", Slug: "pixel-draft", Content: "pixel draft", Status: "draft", Locale: "en", Views: 8} + s.Require().NoError(s.db.Create(draft).Error) r := gin.New() r.GET("/hc/:slug/articles/:article_slug", s.handler.PublicArticle) @@ -308,10 +345,27 @@ func (s *ArticleHandlerTestSuite) TestPublicTrackingPixel_IncrementsPublishedArt assert.Equal(s.T(), http.StatusOK, w.Code) assert.Equal(s.T(), "image/png", w.Header().Get("Content-Type")) - assert.NotEmpty(s.T(), w.Body.Bytes()) + assert.Equal(s.T(), "private, max-age=86400", w.Header().Get("Cache-Control")) + assert.Equal(s.T(), publicArticleTrackingPixelPNG, w.Body.Bytes()) + config, err := imagepng.DecodeConfig(bytes.NewReader(w.Body.Bytes())) + s.Require().NoError(err) + assert.Equal(s.T(), 1, config.Width) + assert.Equal(s.T(), 1, config.Height) var updated model.Article s.Require().NoError(s.db.First(&updated, article.ID).Error) assert.Equal(s.T(), 4, updated.Views) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/hc/pixel-portal/articles/pixel-draft.png", nil) + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + assert.Equal(s.T(), "image/png", w.Header().Get("Content-Type")) + assert.Equal(s.T(), "private, max-age=86400", w.Header().Get("Cache-Control")) + assert.Equal(s.T(), publicArticleTrackingPixelPNG, w.Body.Bytes()) + var unchangedDraft model.Article + s.Require().NoError(s.db.First(&unchangedDraft, draft.ID).Error) + assert.Equal(s.T(), 8, unchangedDraft.Views) } func (s *ArticleHandlerTestSuite) TestStatusCounts_BadRequest_InvalidPortalID() { @@ -450,6 +504,44 @@ func (s *ArticleHandlerTestSuite) TestPublicList_WidgetPopularArticles() { assert.EqualValues(s.T(), 2, meta["articles_count"]) } +func (s *ArticleHandlerTestSuite) TestPublicList_CategoryRouteFiltersArticlesBySlugAndLocale() { + r := gin.New() + r.GET("/hc/:slug/:locale/categories/:category_slug/articles.json", s.handler.PublicList) + + category := &model.Category{AccountID: s.account.ID, PortalID: s.portal.ID, Name: "Guides", Slug: "guides", Locale: "en"} + otherCategory := &model.Category{AccountID: s.account.ID, PortalID: s.portal.ID, Name: "Other", Slug: "other", Locale: "en"} + s.Require().NoError(s.db.Create(category).Error) + s.Require().NoError(s.db.Create(otherCategory).Error) + inCategory := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, CategoryID: &category.ID, Title: "Guide", Slug: "category-guide", Status: "published", Locale: "en", Position: 1, Content: "guide"} + other := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, CategoryID: &otherCategory.ID, Title: "Other", Slug: "category-other", Status: "published", Locale: "en", Position: 2, Content: "other"} + french := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, CategoryID: &category.ID, Title: "French Guide", Slug: "category-guide-fr", Status: "published", Locale: "fr", Position: 3, Content: "guide fr"} + draft := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, CategoryID: &category.ID, Title: "Draft Guide", Slug: "category-guide-draft", Status: "draft", Locale: "en", Position: 4, Content: "draft"} + s.Require().NoError(s.db.Create(inCategory).Error) + s.Require().NoError(s.db.Create(other).Error) + s.Require().NoError(s.db.Create(french).Error) + s.Require().NoError(s.db.Create(draft).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/hc/test-portal/en/categories/guides/articles.json", nil) + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var resp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + payload := resp["payload"].([]interface{}) + assert.Len(s.T(), payload, 1) + article := payload[0].(map[string]interface{}) + assert.Equal(s.T(), "Guide", article["title"]) + assert.Equal(s.T(), "category-guide", article["slug"]) + assert.Equal(s.T(), "guides", article["category"].(map[string]interface{})["slug"]) + assert.NotContains(s.T(), w.Body.String(), "category-other") + assert.NotContains(s.T(), w.Body.String(), "category-guide-fr") + assert.NotContains(s.T(), w.Body.String(), "category-guide-draft") + meta := resp["meta"].(map[string]interface{}) + assert.EqualValues(s.T(), 1, meta["articles_count"]) + assert.EqualValues(s.T(), 1, meta["current_page"]) +} + func (s *ArticleHandlerTestSuite) TestPublicList_ArchivedPortalNotFound() { r := gin.New() r.GET("/hc/:slug/:locale/articles.json", s.handler.PublicList) @@ -490,6 +582,103 @@ func (s *ArticleHandlerTestSuite) TestCreate_RawFrontendPayloadAndSlugPortal() { assert.NotEmpty(s.T(), payload["slug"]) } +func (s *ArticleHandlerTestSuite) TestDashboardArticleCRUD_ChatwootFrontendPayloadsAndRoutes() { + category := &model.Category{AccountID: s.account.ID, PortalID: s.portal.ID, Name: "Dashboard Guides", Slug: "dashboard-guides", Locale: "en"} + s.Require().NoError(s.db.Create(category).Error) + author := &model.User{Name: "Article Author", Email: "article-author@example.com", Role: "agent"} + s.Require().NoError(s.db.Create(author).Error) + + r := gin.New() + r.POST("/api/v1/accounts/:account_id/portals/:portal_id/articles", s.handler.Create) + r.GET("/api/v1/accounts/:account_id/portals/:portal_id/articles", s.handler.List) + r.GET("/api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id", s.handler.Get) + r.GET("/api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id/edit", s.handler.Edit) + r.PATCH("/api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id", s.handler.Update) + r.DELETE("/api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id", s.handler.Delete) + + createBody := fmt.Sprintf(`{"article":{"title":"Dashboard CRUD Article","content":"# dashboard content","description":"dashboard description","status":"draft","author_id":%d,"category_id":%d,"locale":"en","position":7,"meta":{"seo_title":"Dashboard SEO"}}}`, author.ID, category.ID) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles", s.account.ID), bytes.NewBufferString(createBody)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var createResp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &createResp)) + created := createResp["payload"].(map[string]interface{}) + articleID := uint(created["id"].(float64)) + assertDashboardArticlePayload(s.T(), created, map[string]interface{}{ + "title": "Dashboard CRUD Article", + "content": "# dashboard content", + "description": "dashboard description", + "status": "draft", + "position": float64(7), + }) + assert.Contains(s.T(), created["slug"], "dashboard-crud-article") + assert.Equal(s.T(), "Dashboard Guides", created["category"].(map[string]interface{})["name"]) + assert.Equal(s.T(), "Article Author", created["author"].(map[string]interface{})["name"]) + assert.Equal(s.T(), "Dashboard SEO", created["meta"].(map[string]interface{})["seo_title"]) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles?status=draft&locale=en&category_slug=dashboard-guides&sort=position", s.account.ID), nil) + r.ServeHTTP(w, req) + assert.Equal(s.T(), http.StatusOK, w.Code) + var listResp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &listResp)) + articles := listResp["payload"].([]interface{}) + s.Require().Len(articles, 1) + listed := articles[0].(map[string]interface{}) + assert.EqualValues(s.T(), articleID, listed["id"]) + meta := listResp["meta"].(map[string]interface{}) + assert.EqualValues(s.T(), 1, meta["articles_count"]) + assert.Contains(s.T(), meta, "all_articles_count") + assert.Contains(s.T(), meta, "draft_articles_count") + assert.Contains(s.T(), meta, "published_count") + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles/%d/edit", s.account.ID, articleID), nil) + r.ServeHTTP(w, req) + assert.Equal(s.T(), http.StatusOK, w.Code) + var editResp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &editResp)) + assert.Equal(s.T(), "# dashboard content", editResp["payload"].(map[string]interface{})["content"]) + + updateBody := `{"article":{"title":"Dashboard CRUD Article Updated","content":"updated content","description":"","status":"published","position":3,"meta":{"seo_description":"updated seo"}}}` + w = httptest.NewRecorder() + req, _ = http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles/%d", s.account.ID, articleID), bytes.NewBufferString(updateBody)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + assert.Equal(s.T(), http.StatusOK, w.Code) + var updateResp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &updateResp)) + updated := updateResp["payload"].(map[string]interface{}) + assertDashboardArticlePayload(s.T(), updated, map[string]interface{}{ + "title": "Dashboard CRUD Article Updated", + "content": "updated content", + "description": "", + "status": "published", + "position": float64(3), + }) + assert.Equal(s.T(), "updated seo", updated["meta"].(map[string]interface{})["seo_description"]) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles/%d", s.account.ID, articleID), nil) + r.ServeHTTP(w, req) + assert.Equal(s.T(), http.StatusOK, w.Code) + var showResp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &showResp)) + shown := showResp["payload"].(map[string]interface{}) + assert.EqualValues(s.T(), 1, shown["views"]) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles/%d", s.account.ID, articleID), nil) + r.ServeHTTP(w, req) + assert.Equal(s.T(), http.StatusOK, w.Code) + var count int64 + s.Require().NoError(s.db.Model(&model.Article{}).Where("id = ?", articleID).Count(&count).Error) + assert.EqualValues(s.T(), 0, count) +} + func (s *ArticleHandlerTestSuite) TestPatch_RawPayloadClearsDescription() { article := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "patch-article", Slug: "patch-article", Description: "old", Status: "draft"} s.Require().NoError(s.db.Create(article).Error) diff --git a/internal/handler/api/v1/assignable_agent_handler_test.go b/internal/handler/api/v1/assignable_agent_handler_test.go index a0fbb3cc..dfa73052 100644 --- a/internal/handler/api/v1/assignable_agent_handler_test.go +++ b/internal/handler/api/v1/assignable_agent_handler_test.go @@ -70,6 +70,7 @@ func (s *AssignableAgentHandlerTestSuite) SetupSuite() { // Register route matching the handler's expected URL pattern accountGroup := r.Group("/api/v1/accounts/:account_id") { + accountGroup.GET("/assignable_agents", s.handler.List) inboxes := accountGroup.Group("/inboxes/:inbox_id") { inboxes.GET("/assignable_agents", s.handler.List) @@ -277,6 +278,33 @@ func (s *AssignableAgentHandlerTestSuite) TestList_MultipleInboxIDsQueryParam() s.Len(data, 2) } +func (s *AssignableAgentHandlerTestSuite) TestList_StandaloneResourceUsesFrontendInboxIDsQuery() { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", + fmt.Sprintf("/api/v1/accounts/%d/assignable_agents?inbox_ids[]=%d&inbox_ids[]=%d", + s.account.ID, s.inbox1.ID, s.inbox2.ID), nil) + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code) + + data := s.decodeAssignablePayload(w) + s.Len(data, 2) + for _, item := range data { + agent := item.(map[string]interface{}) + s.Contains(agent, "id") + s.Equal(float64(s.account.ID), agent["account_id"]) + s.Contains(agent, "availability_status") + s.Contains(agent, "available_name") + s.Contains(agent, "auto_offline") + s.Contains(agent, "confirmed") + s.Contains(agent, "email") + s.Contains(agent, "provider") + s.Contains(agent, "role") + s.Contains(agent, "thumbnail") + s.Contains(agent, "custom_role_id") + } +} + func (s *AssignableAgentHandlerTestSuite) TestList_MultipleInboxIDsQueryParams_NoIntersection() { // Request inbox1 + inbox3 via query param. inbox3 has no members. // Intersection of {user1, user2} ∩ {} = {} (empty). diff --git a/internal/handler/api/v1/audit_handler.go b/internal/handler/api/v1/audit_handler.go index 7bc1d3ab..aaa0a634 100644 --- a/internal/handler/api/v1/audit_handler.go +++ b/internal/handler/api/v1/audit_handler.go @@ -92,6 +92,7 @@ func (h *AuditHandler) Get(c *gin.Context) { func RegisterAuditRoutes(rg *gin.RouterGroup, h *AuditHandler) { audits := rg.Group("/audit_logs") { + audits.GET("", h.List) audits.GET("/", h.List) audits.GET("/:id", h.Get) } diff --git a/internal/handler/api/v1/audit_handler_test.go b/internal/handler/api/v1/audit_handler_test.go index 90b64e12..b454ee81 100644 --- a/internal/handler/api/v1/audit_handler_test.go +++ b/internal/handler/api/v1/audit_handler_test.go @@ -158,6 +158,45 @@ func (s *AuditHandlerTestSuite) TestList_AssociatedAccountScopeAndFixedPageSize( assert.Len(s.T(), body["audit_logs"].([]any), 1) } +func (s *AuditHandlerTestSuite) TestList_ChatwootPaginationMetaAndDescendingOrder() { + r := gin.New() + r.GET("/api/v1/accounts/:account_id/audit_logs", withAuditRole("administrator", s.handler.List)) + + associatedID := s.account.ID + createdAt := time.Unix(1710000000, 0).UTC() + for i := 0; i < 30; i++ { + s.Require().NoError(s.db.Create(&model.Audit{ + AuditableType: "Conversation", + AuditableID: uint(i + 1), + Action: "update", + AuditedChanges: json.RawMessage(`{}`), + AssociatedType: "Account", + AssociatedID: &associatedID, + CreatedAt: createdAt.Add(time.Duration(i) * time.Minute), + }).Error) + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/audit_logs?page=2", s.account.ID), nil) + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var body map[string]any + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(s.T(), float64(25), body["per_page"]) + assert.Equal(s.T(), float64(2), body["current_page"]) + assert.Equal(s.T(), float64(30), body["total_entries"]) + + logs := body["audit_logs"].([]any) + s.Require().Len(logs, 5) + for index, item := range logs { + log := item.(map[string]any) + expectedAuditableID := float64(5 - index) + assert.Equal(s.T(), expectedAuditableID, log["auditable_id"]) + assert.Equal(s.T(), float64(createdAt.Add(time.Duration(4-index)*time.Minute).Unix()), log["created_at"]) + } +} + func (s *AuditHandlerTestSuite) TestList_UnauthorizedForAgent() { r := gin.New() r.GET("/api/v1/accounts/:account_id/audit_logs", withAuditRole("agent", s.handler.List)) diff --git a/internal/handler/api/v1/auth_handler_test.go b/internal/handler/api/v1/auth_handler_test.go index 5c731b88..edd92ae6 100644 --- a/internal/handler/api/v1/auth_handler_test.go +++ b/internal/handler/api/v1/auth_handler_test.go @@ -40,10 +40,12 @@ func setupChatwootAuthTest(t *testing.T) (*gin.Engine, *gorm.DB, *model.User) { AccountID: account.ID, Name: "Auth User", Email: "auth@example.com", + UID: "auth@example.com", PasswordDigest: passwordDigest, Provider: "email", DisplayName: "Auth Display", MessageSignature: "Cheers", + PubsubToken: "pubsub-auth-user", ConfirmedAt: &confirmedAt, Active: true, } @@ -82,14 +84,39 @@ func TestChatwootAuthSignInReturnsDeviseHeadersAndUserPayload(t *testing.T) { var payload map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload)) data := payload["data"].(map[string]any) + assertChatwootAuthUserFixture(t, data) +} + +func assertChatwootAuthUserFixture(t *testing.T, data map[string]any) { + t.Helper() + require.Equal(t, "Auth User", data["name"]) + require.Equal(t, "auth@example.com", data["email"]) + require.Equal(t, "auth@example.com", data["uid"]) require.Equal(t, "Auth Display", data["available_name"]) + require.Equal(t, "Auth Display", data["display_name"]) + require.Equal(t, "", data["avatar_url"]) + require.Equal(t, "User", data["type"]) + require.Equal(t, "email", data["provider"]) + require.Equal(t, "pubsub-auth-user", data["pubsub_token"]) + require.Equal(t, "Cheers", data["message_signature"]) require.Equal(t, "auth-profile-token", data["access_token"]) require.Equal(t, "administrator", data["role"]) + require.Equal(t, map[string]any{}, data["custom_attributes"]) + require.Equal(t, map[string]any{}, data["ui_settings"]) + require.Equal(t, true, data["confirmed"]) + require.NotNil(t, data["account_id"]) accounts := data["accounts"].([]any) + require.Len(t, accounts, 1) account := accounts[0].(map[string]any) + require.Equal(t, "Auth Account", account["name"]) + require.Equal(t, "active", account["status"]) + require.Equal(t, "invite_team", account["onboarding_step"]) + require.Equal(t, "administrator", account["role"]) require.Equal(t, "online", account["availability"]) + require.Equal(t, "online", account["availability_status"]) require.Equal(t, true, account["auto_offline"]) + require.Equal(t, []any{"administrator"}, account["permissions"]) } func TestChatwootAuthValidateTokenReturnsPayloadData(t *testing.T) { @@ -107,7 +134,7 @@ func TestChatwootAuthValidateTokenReturnsPayloadData(t *testing.T) { payload := body["payload"].(map[string]any) require.Equal(t, true, payload["success"]) data := payload["data"].(map[string]any) - require.Equal(t, "auth@example.com", data["email"]) + assertChatwootAuthUserFixture(t, data) } func TestChatwootAuthSignOutRevokesRefreshSession(t *testing.T) { diff --git a/internal/handler/api/v1/automation_rule_handler_test.go b/internal/handler/api/v1/automation_rule_handler_test.go index e61aeea3..d77509cb 100644 --- a/internal/handler/api/v1/automation_rule_handler_test.go +++ b/internal/handler/api/v1/automation_rule_handler_test.go @@ -305,6 +305,103 @@ func (s *AutomationRuleHandlerTestSuite) TestUpdate_OmittedActionsPreservesExist s.Equal(float64(1), saved.Actions[0].ActionParams["team_id"]) } +func (s *AutomationRuleHandlerTestSuite) TestChatwootFrontendCRUDCloneTogglePayloadsAndValidation() { + createBody := `{"event_name":"conversation_created","name":"Frontend Rule","description":"Created from Woochat","active":true,"conditions":[{"attribute_key":"status","filter_operator":"equal_to","values":["open"],"query_operator":"and"}],"actions":[{"action_name":"add_label","action_params":["vip","trial"]}]}` + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/automation_rules", bytes.NewBufferString(createBody)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code) + + var createResp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &createResp)) + s.assertChatwootAutomationRulePayload(createResp, "Frontend Rule", true) + s.Equal("conversation_created", createResp["event_name"]) + condition := createResp["conditions"].([]interface{})[0].(map[string]interface{}) + s.Equal("status", condition["attribute_key"]) + s.Equal("equal_to", condition["filter_operator"]) + s.NotContains(condition, "attribute") + action := createResp["actions"].([]interface{})[0].(map[string]interface{}) + s.Equal("add_label", action["action_name"]) + s.Equal([]interface{}{"vip", "trial"}, action["action_params"]) + ruleID := uint(createResp["id"].(float64)) + + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/automation_rules", nil) + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code) + var listResp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &listResp)) + rules := listResp["payload"].([]interface{}) + s.Require().Len(rules, 1) + s.assertChatwootAutomationRulePayload(rules[0].(map[string]interface{}), "Frontend Rule", true) + + updateBody := `{"event_name":"message_created","name":"Frontend Rule Updated","description":"Updated from Woochat","active":false,"conditions":[{"attribute_key":"content","filter_operator":"contains","values":["refund"],"query_operator":"and"}],"actions":[{"action_name":"change_status","action_params":["resolved"]}]}` + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/accounts/1/automation_rules/%d", ruleID), bytes.NewBufferString(updateBody)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code) + var updateResp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &updateResp)) + updatedPayload := updateResp["payload"].(map[string]interface{}) + s.assertChatwootAutomationRulePayload(updatedPayload, "Frontend Rule Updated", false) + s.Equal("message_created", updatedPayload["event_name"]) + s.Equal([]interface{}{"resolved"}, updatedPayload["actions"].([]interface{})[0].(map[string]interface{})["action_params"]) + + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/accounts/1/automation_rules/%d/clone", ruleID), nil) + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code) + var cloneResp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &cloneResp)) + clonePayload := cloneResp["payload"].(map[string]interface{}) + s.assertChatwootAutomationRulePayload(clonePayload, "Frontend Rule Updated (copy)", false) + s.NotEqual(float64(ruleID), clonePayload["id"]) + + toggleBody := `{"active":true}` + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/accounts/1/automation_rules/%d/toggle_active", ruleID), bytes.NewBufferString(toggleBody)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code) + var toggleResp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &toggleResp)) + s.assertChatwootAutomationRulePayload(toggleResp["payload"].(map[string]interface{}), "Frontend Rule Updated", true) + + invalidBody := `{"event_name":"conversation_created","active":true}` + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/automation_rules", bytes.NewBufferString(invalidBody)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + s.Equal(http.StatusUnprocessableEntity, w.Code) + var validationResp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &validationResp)) + validationError := validationResp["error"].(map[string]interface{}) + s.Equal("VALIDATION_ERROR", validationError["code"]) + s.Contains(validationError["message"], "name and event_name are required") + + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/v1/accounts/1/automation_rules/%d", ruleID), nil) + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code) + s.Empty(w.Body.String()) +} + +func (s *AutomationRuleHandlerTestSuite) assertChatwootAutomationRulePayload(payload map[string]interface{}, name string, active bool) { + s.NotContains(payload, "success") + s.NotContains(payload, "data") + s.Contains(payload, "id") + s.Equal(float64(1), payload["account_id"]) + s.Equal(name, payload["name"]) + s.Equal(active, payload["active"]) + s.Contains(payload, "description") + s.Contains(payload, "event_name") + s.Contains(payload, "conditions") + s.Contains(payload, "actions") + s.Contains(payload, "created_on") +} + func (s *AutomationRuleHandlerTestSuite) TestUpdate_InvalidID() { body := `{"name":"Updated","active":true}` w := httptest.NewRecorder() diff --git a/internal/handler/api/v1/bulk_action_handler_test.go b/internal/handler/api/v1/bulk_action_handler_test.go index 57438e9d..0e78052d 100644 --- a/internal/handler/api/v1/bulk_action_handler_test.go +++ b/internal/handler/api/v1/bulk_action_handler_test.go @@ -2,6 +2,7 @@ package v1 import ( "bytes" + "encoding/json" "net/http" "net/http/httptest" "testing" @@ -41,11 +42,37 @@ func TestBulkActionHandler_ConversationEnqueuesChatwootPayload(t *testing.T) { var job model.BackgroundJob require.NoError(t, db.Where("job_type = ? AND queue = ?", service.TaskTypeConversationBulkAction, "medium").First(&job).Error) + require.Equal(t, model.BackgroundJobStatusQueued, job.Status) + require.NotZero(t, job.ScheduledAt) require.Contains(t, string(job.Payload), `"account_id":7`) require.Contains(t, string(job.Payload), `"user_id":42`) require.Contains(t, string(job.Payload), `"ids":[101,102]`) require.Contains(t, string(job.Payload), `"status":"resolved"`) require.Contains(t, string(job.Payload), `"add":["vip"]`) + var payload struct { + AccountID uint `json:"account_id"` + UserID uint `json:"user_id"` + Params struct { + Type string `json:"type"` + IDs []uint `json:"ids"` + Fields struct { + Status *string `json:"status"` + } `json:"fields"` + Labels struct { + Add []string `json:"add"` + Remove []string `json:"remove"` + } `json:"labels"` + } `json:"params"` + } + require.NoError(t, json.Unmarshal(job.Payload, &payload)) + require.Equal(t, uint(7), payload.AccountID) + require.Equal(t, uint(42), payload.UserID) + require.Equal(t, "Conversation", payload.Params.Type) + require.Equal(t, []uint{101, 102}, payload.Params.IDs) + require.NotNil(t, payload.Params.Fields.Status) + require.Equal(t, "resolved", *payload.Params.Fields.Status) + require.Equal(t, []string{"vip"}, payload.Params.Labels.Add) + require.Equal(t, []string{"old"}, payload.Params.Labels.Remove) } func TestBulkActionHandler_ContactEnqueuesChatwootPayload(t *testing.T) { diff --git a/internal/handler/api/v1/campaign_handler_test.go b/internal/handler/api/v1/campaign_handler_test.go index be083da3..baf27da0 100644 --- a/internal/handler/api/v1/campaign_handler_test.go +++ b/internal/handler/api/v1/campaign_handler_test.go @@ -2,6 +2,7 @@ package v1 import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" @@ -15,6 +16,7 @@ import ( "gorm.io/gorm/logger" "github.com/gochat/gochat/internal/campaign" + "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" @@ -33,9 +35,10 @@ func marshalNested(key string, body map[string]interface{}) []byte { type CampaignHandlerTestSuite struct { suite.Suite - db *gorm.DB - router *gin.Engine - handler *CampaignHandler + db *gorm.DB + router *gin.Engine + handler *CampaignHandler + listener *campaignRecordingListener account *model.Account inbox *model.Inbox @@ -44,6 +47,31 @@ type CampaignHandlerTestSuite struct { displayIDCounter uint } +type campaignRecordingListener struct { + events []*channel.ChannelEvent +} + +func (l *campaignRecordingListener) Name() string { return "campaign_recording_listener" } + +func (l *campaignRecordingListener) OnEvent(_ context.Context, event *channel.ChannelEvent) error { + l.events = append(l.events, event) + return nil +} + +func (l *campaignRecordingListener) reset() { + l.events = nil +} + +func (l *campaignRecordingListener) eventsByType(eventType channel.EventType) []*channel.ChannelEvent { + var events []*channel.ChannelEvent + for _, event := range l.events { + if event.Type == eventType { + events = append(events, event) + } + } + return events +} + func (s *CampaignHandlerTestSuite) nextDisplayID() uint { s.displayIDCounter++ return s.displayIDCounter @@ -60,7 +88,10 @@ func (s *CampaignHandlerTestSuite) SetupSuite() { s.Require().NoError(db.AutoMigrate( &model.Account{}, + &model.Contact{}, + &model.Conversation{}, &model.Inbox{}, + &model.Message{}, &campaign.Campaign{}, ), "failed to auto-migrate models") @@ -68,7 +99,10 @@ func (s *CampaignHandlerTestSuite) SetupSuite() { // Wire repos → services → handler campaignRepo := repository.NewCampaignRepo(db) - campaignSvc := campaign.NewCampaignService(db) + dispatcher := channel.NewDispatcher() + s.listener = &campaignRecordingListener{} + dispatcher.Register(s.listener) + campaignSvc := campaign.NewCampaignService(db, dispatcher) svc := service.NewCampaignService(campaignSvc, campaignRepo) s.handler = NewCampaignHandler(svc) @@ -111,7 +145,11 @@ func (s *CampaignHandlerTestSuite) TearDownSuite() { // SetupTest resets data between tests. func (s *CampaignHandlerTestSuite) SetupTest() { + s.listener.reset() s.db.Exec("DELETE FROM campaigns") + s.db.Exec("DELETE FROM messages") + s.db.Exec("DELETE FROM conversations") + s.db.Exec("DELETE FROM contacts") s.db.Exec("DELETE FROM inboxes") s.db.Exec("DELETE FROM accounts") @@ -402,6 +440,158 @@ func (s *CampaignHandlerTestSuite) TestUpdate_Success() { s.Equal(int64(1780828200), updated.ScheduledAt.Unix()) } +func (s *CampaignHandlerTestSuite) TestChatwootFrontendPayloadsAndLifecycleUseDisplayID() { + s.displayIDCounter = 39 + s.seedCampaign("Existing Campaign", "Existing message", "ongoing") + smsInbox := s.seedInbox("Channel::Sms") + body := map[string]interface{}{ + "inbox_id": smsInbox.ID, + "title": "Frontend Campaign", + "message": "Hello from Woochat", + "description": "Dashboard-created one-off campaign", + "enabled": true, + "scheduled_at": "2026-06-07T10:30:00Z", + "audience": []map[string]interface{}{{"type": "Label", "id": 7}}, + "trigger_rules": map[string]interface{}{"url": "https://example.com/pricing"}, + "template_params": map[string]interface{}{"first_name": "Jane"}, + "trigger_only_during_business_hours": true, + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", s.accountURL(), bytes.NewReader(marshalNested("campaign", body))) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code) + var createResp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &createResp)) + s.assertChatwootCampaignPayload(createResp, "Frontend Campaign", "one_off", smsInbox.ID) + s.Equal(float64(41), createResp["id"]) + s.Equal(float64(1780828200), createResp["scheduled_at"]) + s.Equal([]interface{}{map[string]interface{}{"id": float64(7), "type": "Label"}}, createResp["audience"]) + s.Equal(map[string]interface{}{"first_name": "Jane"}, createResp["template_params"]) + s.Equal(map[string]interface{}{"url": "https://example.com/pricing"}, createResp["trigger_rules"]) + s.Equal(true, createResp["trigger_only_during_business_hours"]) + + createdDisplayID := uint(createResp["id"].(float64)) + var created campaign.Campaign + s.Require().NoError(s.db.Where("display_id = ? AND account_id = ?", createdDisplayID, s.account.ID).First(&created).Error) + s.NotEqual(created.ID, createdDisplayID) + + updateBody := map[string]interface{}{ + "inbox_id": s.inbox.ID, + "title": "Frontend Campaign Updated", + "message": "Updated ongoing message", + "scheduled_at": nil, + "trigger_rules": map[string]interface{}{"time_on_page": 10}, + } + w = httptest.NewRecorder() + req, _ = http.NewRequest("PATCH", s.accountURL()+"/"+strconv.FormatUint(uint64(createdDisplayID), 10), bytes.NewReader(marshalNested("campaign", updateBody))) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code) + var updateResp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &updateResp)) + s.assertChatwootCampaignPayload(updateResp, "Frontend Campaign Updated", "ongoing", s.inbox.ID) + s.Equal(float64(createdDisplayID), updateResp["id"]) + s.NotContains(updateResp, "scheduled_at") + s.NotContains(updateResp, "audience") + s.Equal(map[string]interface{}{"time_on_page": float64(10)}, updateResp["trigger_rules"]) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("POST", s.accountURL()+"/"+strconv.FormatUint(uint64(createdDisplayID), 10)+"/start", nil) + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code) + var startResp struct { + Success bool `json:"success"` + Data map[string]interface{} `json:"data"` + } + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &startResp)) + s.True(startResp.Success) + s.Equal("campaign triggered successfully", startResp.Data["message"]) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("POST", s.accountURL()+"/"+strconv.FormatUint(uint64(createdDisplayID), 10)+"/stop", nil) + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code) + var stopResp struct { + Success bool `json:"success"` + Data map[string]interface{} `json:"data"` + } + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &stopResp)) + s.True(stopResp.Success) + s.Equal("campaign stopped successfully", stopResp.Data["message"]) + + var updated campaign.Campaign + s.Require().NoError(s.db.First(&updated, created.ID).Error) + s.Equal(campaign.CampaignStatusCompleted, updated.CampaignStatus) +} + +func (s *CampaignHandlerTestSuite) TestStartCreatesCampaignConversationsAndMessages() { + contact := &model.Contact{AccountID: s.account.ID, Name: "Campaign Contact", Email: "campaign@example.com"} + s.Require().NoError(s.db.Create(contact).Error) + c := s.seedCampaign("Trigger Campaign", "Triggered campaign message", "one_off") + s.Require().NoError(s.db.Model(&campaign.Campaign{}).Where("id = ?", c.ID).Update("audience", `{"contact_ids":[`+strconv.FormatUint(uint64(contact.ID), 10)+`]}`).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", s.accountURL()+"/"+strconv.FormatUint(uint64(c.DisplayID), 10)+"/start", nil) + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code) + + var conv model.Conversation + s.Require().NoError(s.db.Where("account_id = ? AND inbox_id = ? AND contact_id = ? AND campaign_id = ?", s.account.ID, s.inbox.ID, contact.ID, c.ID).First(&conv).Error) + s.Equal("open", conv.Status) + s.Equal("campaign", conv.ChannelType) + s.Require().NotNil(conv.CampaignID) + s.Equal(c.ID, *conv.CampaignID) + + var msg model.Message + s.Require().NoError(s.db.Where("conversation_id = ? AND account_id = ?", conv.ID, s.account.ID).First(&msg).Error) + s.Equal("Triggered campaign message", msg.Content) + s.Equal("template", msg.ContentType) + s.Equal("outgoing", msg.MessageType) + s.Equal("agent", msg.SenderType) + + conversationEvents := s.listener.eventsByType(channel.EventConversationCreated) + s.Require().Len(conversationEvents, 1) + s.Equal(s.account.ID, conversationEvents[0].AccountID) + s.Equal(s.inbox.ID, conversationEvents[0].InboxID) + s.Equal(conv.ID, conversationEvents[0].ConversationID) + s.Equal(contact.ID, conversationEvents[0].ContactID) + s.Equal(channel.ChannelType(s.inbox.ChannelType), conversationEvents[0].Channel) + s.Equal(c.ID, conversationEvents[0].Data["campaign_id"]) + s.Equal(conv.ID, conversationEvents[0].Data["conversation"].(*model.Conversation).ID) + + messageEvents := s.listener.eventsByType(channel.EventMessageCreated) + s.Require().Len(messageEvents, 1) + s.Equal(conv.ID, messageEvents[0].ConversationID) + s.Equal(c.ID, messageEvents[0].Data["campaign_id"]) + s.Equal(msg.ID, messageEvents[0].Data["message"].(*model.Message).ID) + s.Equal(s.inbox.ID, messageEvents[0].Data["inbox"].(*model.Inbox).ID) + s.Len(s.listener.eventsByType(channel.EventConversationOpened), 1) + s.Len(s.listener.eventsByType(channel.EventMessageOutgoing), 1) +} + +func (s *CampaignHandlerTestSuite) assertChatwootCampaignPayload(resp map[string]interface{}, title, campaignType string, inboxID uint) { + s.NotContains(resp, "success") + s.NotContains(resp, "data") + s.Equal(title, resp["title"]) + s.Equal(campaignType, resp["campaign_type"]) + s.Equal(float64(s.account.ID), resp["account_id"]) + s.Equal(string(campaign.CampaignStatusActive), resp["campaign_status"]) + s.Contains(resp, "message") + s.Contains(resp, "description") + s.Contains(resp, "enabled") + s.Contains(resp, "trigger_rules") + s.Contains(resp, "template_params") + s.Contains(resp, "created_at") + s.Contains(resp, "updated_at") + inbox, ok := resp["inbox"].(map[string]interface{}) + s.Require().True(ok) + s.Equal(float64(inboxID), inbox["id"]) +} + func (s *CampaignHandlerTestSuite) TestUpdate_NotFound() { body := map[string]interface{}{ "title": "Updated Title", diff --git a/internal/handler/api/v1/captain_assistant_handler_test.go b/internal/handler/api/v1/captain_assistant_handler_test.go index 93bedecf..f6a59f4e 100644 --- a/internal/handler/api/v1/captain_assistant_handler_test.go +++ b/internal/handler/api/v1/captain_assistant_handler_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -292,13 +293,40 @@ func TestCaptainAssistantHandler_PlaygroundV2AppendsCurrentMessageOnce(t *testin assert.Equal(t, "Hello assistant", provider.lastRequest.Messages[1].Content) } +func TestCaptainAssistantHandler_PlaygroundV2ProviderErrorReturnsChatwootFallback(t *testing.T) { + provider := &captainPlaygroundFakeProvider{err: errors.New("provider unavailable")} + router, db := setupCaptainAssistantHandlerTestWithProvider(t, provider) + account := seedCaptainAssistantAccount(t, db, "Captain Org") + account.FeatureFlags = `{"captain_integration_v2":true}` + require.NoError(t, db.Save(account).Error) + assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Fin", Description: "Support", Config: json.RawMessage(`{"model":"gpt-test"}`), Status: model.AssistantStatusActive} + require.NoError(t, db.Create(assistant).Error) + + body := map[string]any{"message_content": "Hello assistant"} + w := captainAssistantJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/captain/assistants/%d/playground", account.ID, assistant.ID), body) + assert.Equal(t, http.StatusOK, w.Code) + + var payload map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload)) + assert.Equal(t, "conversation_handoff", payload["response"]) + assert.Equal(t, false, payload["handoff_tool_called"]) + assert.Contains(t, payload["reasoning"], "Error occurred: llm generation failed: provider unavailable") + assert.NotContains(t, payload, "content") + assert.NotContains(t, payload, "success") + assert.NotContains(t, payload, "data") +} + type captainPlaygroundFakeProvider struct { content string + err error lastRequest llm.ChatRequest } func (p *captainPlaygroundFakeProvider) ChatCompletion(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) { p.lastRequest = req + if p.err != nil { + return nil, p.err + } return &llm.ChatResponse{Choices: []llm.ChatChoice{{Message: llm.ChatMessage{Role: "assistant", Content: p.content}}}}, nil } diff --git a/internal/handler/api/v1/captain_assistant_response_handler.go b/internal/handler/api/v1/captain_assistant_response_handler.go index bbc6d2f8..5a20b586 100644 --- a/internal/handler/api/v1/captain_assistant_response_handler.go +++ b/internal/handler/api/v1/captain_assistant_response_handler.go @@ -209,7 +209,11 @@ func captainResponseAssistantPayload(resp *model.CaptainAssistantResponse) gin.H } func captainResponseDocumentablePayload(resp *model.CaptainAssistantResponse) gin.H { - payload := gin.H{"type": resp.DocumentableType, "id": *resp.DocumentableID} + documentableType := resp.DocumentableType + if documentableType == "CaptainDocument" { + documentableType = "Captain::Document" + } + payload := gin.H{"type": documentableType, "id": *resp.DocumentableID} if resp.DocumentableType == "Conversation" { payload["display_id"] = *resp.DocumentableID } diff --git a/internal/handler/api/v1/captain_resource_parity_handler_test.go b/internal/handler/api/v1/captain_resource_parity_handler_test.go index 50f3293f..df291a31 100644 --- a/internal/handler/api/v1/captain_resource_parity_handler_test.go +++ b/internal/handler/api/v1/captain_resource_parity_handler_test.go @@ -222,7 +222,7 @@ func TestCaptainCustomToolHandler_ChatwootToolPayloadsAndScope(t *testing.T) { } func TestCaptainDocumentHandler_ChatwootDocumentPayloadsAndSync(t *testing.T) { - router, _, account, otherAccount, assistant := setupCaptainResourceParityTest(t) + router, db, account, otherAccount, assistant := setupCaptainResourceParityTest(t) basePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/captain/documents" otherBasePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(otherAccount.ID), 10) + "/captain/documents" @@ -247,6 +247,21 @@ func TestCaptainDocumentHandler_ChatwootDocumentPayloadsAndSync(t *testing.T) { require.NoError(t, json.Unmarshal(w.Body.Bytes(), &listResp)) assert.Len(t, listResp["payload"], 1) assert.Equal(t, float64(1), listResp["meta"].(map[string]any)["total_count"]) + listedDocument := listResp["payload"].([]any)[0].(map[string]any) + for _, key := range []string{"account_id", "assistant", "content", "content_type", "created_at", "external_link", "display_url", "file_size", "pdf_document", "id", "name", "status", "sync_status", "sync_in_progress", "last_synced_at", "last_sync_attempted_at", "last_sync_error_code", "updated_at"} { + assert.Contains(t, listedDocument, key, "captain document should expose Chatwoot key %s", key) + } + + failedDoc := &model.CaptainDocument{AccountID: account.ID, AssistantID: assistant.ID, Name: "Failed Help", ExternalLink: "https://example.com/failed", Status: model.DocumentStatusCompleted, SyncStatus: model.DocumentSyncStatusFailed, LastSyncErrorCode: "timeout"} + require.NoError(t, db.Create(failedDoc).Error) + w = captainResourceJSONRequest(t, router, http.MethodGet, basePath+"/?assistant_id="+strconv.FormatUint(uint64(assistant.ID), 10)+"&filter=failed&source=web&search_key=failed", nil) + assert.Equal(t, http.StatusOK, w.Code) + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &listResp)) + assert.Len(t, listResp["payload"], 1) + filteredDocument := listResp["payload"].([]any)[0].(map[string]any) + assert.Equal(t, float64(failedDoc.ID), filteredDocument["id"]) + assert.Equal(t, "failed", filteredDocument["sync_status"]) + assert.Equal(t, false, filteredDocument["sync_in_progress"]) w = captainResourceJSONRequest(t, router, http.MethodGet, fmt.Sprintf("%s/%d", otherBasePath, documentID), nil) assert.Equal(t, http.StatusNotFound, w.Code) @@ -266,7 +281,7 @@ func TestCaptainDocumentHandler_ChatwootDocumentPayloadsAndSync(t *testing.T) { } func TestCaptainAssistantResponseHandler_ChatwootResponsePayloadsAndFilters(t *testing.T) { - router, _, account, otherAccount, assistant := setupCaptainResourceParityTest(t) + router, db, account, otherAccount, assistant := setupCaptainResourceParityTest(t) basePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/captain/assistant_responses" otherBasePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(otherAccount.ID), 10) + "/captain/assistant_responses" @@ -291,6 +306,24 @@ func TestCaptainAssistantResponseHandler_ChatwootResponsePayloadsAndFilters(t *t require.NoError(t, json.Unmarshal(w.Body.Bytes(), &listResp)) assert.Len(t, listResp["payload"], 1) assert.Equal(t, float64(1), listResp["meta"].(map[string]any)["total_count"]) + listedResponse := listResp["payload"].([]any)[0].(map[string]any) + for _, key := range []string{"account_id", "answer", "assistant", "created_at", "id", "question", "updated_at", "status", "edited"} { + assert.Contains(t, listedResponse, key, "captain response should expose Chatwoot key %s", key) + } + + document := &model.CaptainDocument{AccountID: account.ID, AssistantID: assistant.ID, Name: "FAQ Doc", ExternalLink: "https://example.com/faq", Status: model.DocumentStatusCompleted, SyncStatus: model.DocumentSyncStatusSynced} + require.NoError(t, db.Create(document).Error) + legacyDocumentableType := "CaptainDocument" + documentResponse := &model.CaptainAssistantResponse{AccountID: account.ID, AssistantID: assistant.ID, DocumentableID: &document.ID, DocumentableType: legacyDocumentableType, Question: "Document question", Answer: "Document answer", Status: model.ResponseStatusApproved} + require.NoError(t, db.Create(documentResponse).Error) + w = captainResourceJSONRequest(t, router, http.MethodGet, basePath+"/?document_id="+strconv.FormatUint(uint64(document.ID), 10), nil) + assert.Equal(t, http.StatusOK, w.Code) + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &listResp)) + assert.Len(t, listResp["payload"], 1) + documentFilteredResponse := listResp["payload"].([]any)[0].(map[string]any) + assert.Equal(t, float64(documentResponse.ID), documentFilteredResponse["id"]) + assert.Equal(t, "Captain::Document", documentFilteredResponse["documentable"].(map[string]any)["type"]) + assert.Equal(t, float64(document.ID), documentFilteredResponse["documentable"].(map[string]any)["id"]) w = captainResourceJSONRequest(t, router, http.MethodGet, fmt.Sprintf("%s/%d", otherBasePath, responseID), nil) assert.Equal(t, http.StatusNotFound, w.Code) diff --git a/internal/handler/api/v1/company_handler.go b/internal/handler/api/v1/company_handler.go index 24e20da4..98f53d9f 100644 --- a/internal/handler/api/v1/company_handler.go +++ b/internal/handler/api/v1/company_handler.go @@ -1,6 +1,7 @@ package v1 import ( + "context" "encoding/json" "net/http" "strconv" @@ -8,8 +9,10 @@ import ( "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/search" "github.com/gochat/gochat/internal/service" + "github.com/gochat/gochat/internal/ws" applogger "github.com/gochat/gochat/pkg/logger" "github.com/gochat/gochat/pkg/pagination" "github.com/gochat/gochat/pkg/response" @@ -18,7 +21,8 @@ import ( // CompanyHandler handles Company CRUD + search + nested contacts/conversations/notes. // Reference: Chatwoot app/controllers/api/v1/companies_controller.rb type CompanyHandler struct { - svc *service.CompanyService + svc *service.CompanyService + eventPublisher *ws.EventPublisher } // NewCompanyHandler creates a new CompanyHandler. @@ -26,6 +30,11 @@ func NewCompanyHandler(svc *service.CompanyService) *CompanyHandler { return &CompanyHandler{svc: svc} } +func (h *CompanyHandler) WithEventPublisher(publisher *ws.EventPublisher) *CompanyHandler { + h.eventPublisher = publisher + return h +} + // List retrieves all companies for an account. // GET /api/v1/accounts/:id/companies?sort=name&page=1&per_page=25 func (h *CompanyHandler) List(c *gin.Context) { @@ -100,6 +109,13 @@ func (h *CompanyHandler) Get(c *gin.Context) { c.JSON(http.StatusOK, companyPayloadResponse(c.Request.Context(), h.svc.DB(), company)) } +func (h *CompanyHandler) publishCompanyEvent(accountID uint, eventType string, company *model.Company) { + if h.eventPublisher == nil || company == nil { + return + } + h.eventPublisher.PublishEvent(accountID, eventType, serializeCompany(context.Background(), h.svc.DB(), company)) +} + // Create creates a new company. // POST /api/v1/accounts/:id/companies func (h *CompanyHandler) Create(c *gin.Context) { @@ -151,7 +167,9 @@ func (h *CompanyHandler) Update(c *gin.Context) { return } - c.JSON(http.StatusOK, companyPayloadResponse(c.Request.Context(), h.svc.DB(), company)) + payload := companyPayloadResponse(c.Request.Context(), h.svc.DB(), company) + h.publishCompanyEvent(accountID, ws.EventCompanyUpdated, company) + c.JSON(http.StatusOK, payload) } // Delete deletes a company. diff --git a/internal/handler/api/v1/company_handler_test.go b/internal/handler/api/v1/company_handler_test.go index c0b32e85..a788315e 100644 --- a/internal/handler/api/v1/company_handler_test.go +++ b/internal/handler/api/v1/company_handler_test.go @@ -22,6 +22,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/ws" ) // --- Company Handler Test Suite --- @@ -101,7 +102,9 @@ func (s *CompanyHandlerTestSuite) SetupSuite() { // Register company routes — matches the real router registration pattern companies := s.router.Group("/api/v1/accounts/:id/companies") { + companies.GET("", s.handler.List) companies.GET("/", s.handler.List) + companies.POST("", s.handler.Create) companies.POST("/", s.handler.Create) companies.GET("/search", s.handler.Search) companies.GET("/:company_id", s.handler.Get) @@ -221,6 +224,61 @@ func (s *CompanyHandlerTestSuite) TestList_IgnoresPerPage() { assert.Equal(s.T(), float64(3), resp["meta"].(map[string]interface{})["total_count"]) } +func (s *CompanyHandlerTestSuite) TestChatwootCompanyFrontendLiteralSpecRuntimeRoutes() { + companyRepo := repository.NewCompanyRepo(s.db) + company := &model.Company{AccountID: s.accountID, Name: "Acme & Co", Domain: "acme.example", FaviconURL: "avatar.png", CustomAttributes: datatypes.JSON(`{"plan":"enterprise","region":"apac"}`)} + s.Require().NoError(companyRepo.Create(context.Background(), company)) + + contact := &model.Contact{AccountID: s.accountID, Name: "Jane & Co", Email: "jane@example.com"} + s.Require().NoError(s.db.Create(contact).Error) + + w := s.makeRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/companies?page=1&sort=name", s.accountID), nil) + s.Equal(http.StatusOK, w.Code) + var resp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + s.Require().Len(resp["payload"].([]interface{}), 1) + s.Equal("Acme & Co", resp["payload"].([]interface{})[0].(map[string]interface{})["name"]) + + w = s.makeRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/companies/search?q=acme+%%26+co&page=2&sort=domain", s.accountID), nil) + s.Equal(http.StatusOK, w.Code) + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + s.IsType([]interface{}{}, resp["payload"]) + s.Contains(resp, "meta") + + w = s.makeRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/companies/%d/avatar", s.accountID, company.ID), nil) + s.Equal(http.StatusOK, w.Code) + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + s.Empty(resp["payload"].(map[string]interface{})["avatar_url"]) + + w = s.makeRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/companies/%d/contacts?page=2", s.accountID, company.ID), nil) + s.Equal(http.StatusOK, w.Code) + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + s.Empty(resp["payload"].([]interface{})) + + w = s.makeRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/companies/%d/contacts", s.accountID, company.ID), map[string]interface{}{"contact_id": contact.ID}) + s.Equal(http.StatusOK, w.Code) + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + s.Equal("Jane & Co", resp["payload"].(map[string]interface{})["name"]) + s.Equal(true, resp["payload"].(map[string]interface{})["linked_to_current_company"]) + + w = s.makeRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/companies/%d/contacts/search?q=jane+%%26+co&page=3", s.accountID, company.ID), nil) + s.Equal(http.StatusOK, w.Code) + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + s.IsType([]interface{}{}, resp["payload"]) + s.Contains(resp, "meta") + + w = s.makeRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/companies/%d/destroy_custom_attributes", s.accountID, company.ID), map[string]interface{}{"custom_attributes": []string{"plan"}}) + s.Equal(http.StatusOK, w.Code) + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + attrs := resp["payload"].(map[string]interface{})["custom_attributes"].(map[string]interface{}) + s.NotContains(attrs, "plan") + s.Equal("apac", attrs["region"]) + + w = s.makeRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/companies/%d/contacts/%d", s.accountID, company.ID, contact.ID), nil) + s.Equal(http.StatusOK, w.Code) + s.Empty(w.Body.String()) +} + // ========== Create ========== func (s *CompanyHandlerTestSuite) TestCreate_Success() { @@ -344,6 +402,39 @@ func (s *CompanyHandlerTestSuite) TestUpdate_Success() { assert.Equal(s.T(), "100", additionalAttrs["size"]) } +func (s *CompanyHandlerTestSuite) TestUpdatePublishesChatwootCompanyUpdatedEvent() { + hub := &captureAccountHub{} + s.handler.WithEventPublisher(ws.NewEventPublisherLocal(hub, nil)) + companyRepo := repository.NewCompanyRepo(s.db) + company := &model.Company{AccountID: s.accountID, Name: "RealtimeCorp", Domain: "old.example"} + s.Require().NoError(companyRepo.Create(context.Background(), company)) + + body := map[string]interface{}{ + "company": map[string]interface{}{ + "name": "RealtimeCorp Updated", + "domain": "new.example", + "additional_attributes": map[string]interface{}{"plan": "enterprise"}, + }, + } + w := s.makeRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/companies/%d", s.accountID, company.ID), body) + assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String()) + + s.Require().NotNil(hub.accountData) + s.Equal(s.accountID, hub.accountID) + var event ws.WSMessage + s.Require().NoError(json.Unmarshal(hub.accountData, &event)) + s.Equal(ws.EventCompanyUpdated, event.Event) + s.Equal(s.accountID, event.AccountID) + data := event.Data.(map[string]interface{}) + s.Equal(float64(company.ID), data["id"]) + s.Equal("RealtimeCorp Updated", data["name"]) + s.Equal("new.example", data["domain"]) + s.Contains(data, "additional_attributes") + s.Contains(data, "custom_attributes") + s.Contains(data, "contacts_count") + s.Contains(data, "updated_at") +} + func (s *CompanyHandlerTestSuite) TestUpdate_MultipartAvatar() { companyRepo := repository.NewCompanyRepo(s.db) company := &model.Company{AccountID: s.accountID, Name: "AvatarUpdateCorp"} diff --git a/internal/handler/api/v1/contact_handler.go b/internal/handler/api/v1/contact_handler.go index 7bca4e8d..c84bb7e3 100644 --- a/internal/handler/api/v1/contact_handler.go +++ b/internal/handler/api/v1/contact_handler.go @@ -16,6 +16,7 @@ import ( "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/search" "github.com/gochat/gochat/internal/service" + "github.com/gochat/gochat/internal/ws" "github.com/gochat/gochat/pkg/response" ) @@ -28,6 +29,7 @@ type ContactHandler struct { contactNoteSvc *service.ContactNoteService conversationSvc *service.ConversationService presence contactPresenceReader + eventPublisher *ws.EventPublisher } const chatwootContactResultsPerPage = 15 @@ -41,6 +43,11 @@ func NewContactHandler(svc *service.ContactService, contactInboxSvc *service.Con return h } +func (h *ContactHandler) WithEventPublisher(publisher *ws.EventPublisher) *ContactHandler { + h.eventPublisher = publisher + return h +} + func (h *ContactHandler) WithContactPresence(presence contactPresenceReader) *ContactHandler { h.presence = presence return h @@ -273,7 +280,16 @@ func (h *ContactHandler) Update(c *gin.Context) { return } - c.JSON(http.StatusOK, contactPayloadResponse(h.requestContext(c), h.svc.DB(), contact, includeContactInboxes(c))) + payload := contactPayloadResponse(h.requestContext(c), h.svc.DB(), contact, includeContactInboxes(c)) + h.publishContactEvent(accountID, ws.EventContactUpdated, contact) + c.JSON(http.StatusOK, payload) +} + +func (h *ContactHandler) publishContactEvent(accountID uint, eventType string, contact *model.Contact) { + if h.eventPublisher == nil || contact == nil { + return + } + h.eventPublisher.PublishEvent(accountID, eventType, serializeCRMContact(context.Background(), h.svc.DB(), contact, true)) } // @Summary Delete a contact @@ -304,11 +320,17 @@ func (h *ContactHandler) Delete(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid contact id"}) return } + contact, svcErr := h.svc.GetByAccountAndID(c.Request.Context(), accountID, contactID) + if svcErr != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to delete contact"}) + return + } if svcErr := h.svc.Delete(c.Request.Context(), accountID, contactID); svcErr != nil { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to delete contact"}) return } + h.publishContactEvent(accountID, ws.EventContactDeleted, contact) c.Status(http.StatusOK) } @@ -1081,6 +1103,10 @@ func (h *ContactHandler) Merge(c *gin.Context) { handleServiceError(c, svcErr) return } + h.publishContactEvent(accountID, ws.EventContactUpdated, result) + if req.BaseContactID != req.MergeeContactID { + h.publishContactEvent(accountID, ws.EventContactDeleted, &model.Contact{Base: model.Base{ID: req.MergeeContactID}, AccountID: accountID}) + } c.JSON(http.StatusOK, serializeCRMContact(h.requestContext(c), h.svc.DB(), result, false)) } diff --git a/internal/handler/api/v1/contact_handler_crud_test.go b/internal/handler/api/v1/contact_handler_crud_test.go index 5a9bee80..4fbae1df 100644 --- a/internal/handler/api/v1/contact_handler_crud_test.go +++ b/internal/handler/api/v1/contact_handler_crud_test.go @@ -3,11 +3,13 @@ package v1 import ( "bytes" "context" + "encoding/csv" "encoding/json" "fmt" "mime/multipart" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -21,6 +23,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/ws" ) // ContactHandlerCRUDTestSuite tests ContactHandler core CRUD methods @@ -107,6 +110,7 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() { s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/attachments", s.handler.ListAttachments) s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/labels", s.handler.ListLabels) s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/labels", s.handler.UpdateLabels) + s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/contactable_inboxes", s.handler.ContactableInboxes) s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/contact_inboxes", s.handler.ListContactInboxes) s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/contact_inboxes", s.handler.CreateContactInbox) s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/destroy_custom_attributes", s.handler.DestroyCustomAttributes) @@ -319,6 +323,155 @@ func (s *ContactHandlerCRUDTestSuite) TestList_Pagination() { s.Len(payload, 2) } +func (s *ContactHandlerCRUDTestSuite) TestChatwootFrontendContactsSpecRuntimeRoutes() { + firstLabel := &model.Tag{AccountID: s.account.ID, Name: "customer-support"} + s.Require().NoError(s.db.Create(firstLabel).Error) + s.Require().NoError(s.db.Create(&model.ContactLabel{AccountID: s.account.ID, ContactID: s.contact.ID, TagID: firstLabel.ID}).Error) + s.Require().NoError(s.db.Model(s.contact).Updates(map[string]any{ + "name": "Leads Contact", + "email": "leads@example.com", + "avatar_url": "https://example.com/avatar.png", + "custom_attributes": datatypes.JSON([]byte(`{"cloudCustomer":"yes","tier":"gold"}`)), + }).Error) + + inbox := &model.Inbox{AccountID: s.account.ID, Name: "Frontend Inbox", ChannelType: "Channel::WebWidget", ChannelID: 10, Enabled: true, ChannelConfig: `{"provider":"web"}`} + s.Require().NoError(s.db.Create(inbox).Error) + s.Require().NoError(s.db.Create(&model.ContactInbox{ContactID: s.contact.ID, InboxID: inbox.ID, SourceID: "frontend-source"}).Error) + + now := time.Now().Unix() + conversation := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: s.contact.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget", LastActivityAt: &now} + s.Require().NoError(s.db.Create(conversation).Error) + + cases := []struct { + name string + method string + path string + body []byte + check func(map[string]any) + }{ + { + name: "list with include_contact_inboxes false and label filter", + method: http.MethodGet, + path: fmt.Sprintf("/api/v1/accounts/%d/contacts?include_contact_inboxes=false&page=1&sort=name&labels[]=customer-support", s.account.ID), + check: func(resp map[string]any) { + s.Contains(resp, "payload") + payload := resp["payload"].([]any) + s.Len(payload, 1) + contact := payload[0].(map[string]any) + s.Equal(float64(s.contact.ID), contact["id"]) + s.NotContains(contact, "contact_inboxes") + }, + }, + { + name: "search with same params", + method: http.MethodGet, + path: fmt.Sprintf("/api/v1/accounts/%d/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support", s.account.ID), + check: func(resp map[string]any) { + s.Contains(resp, "payload") + s.NotContains(resp["payload"].([]any)[0].(map[string]any), "contact_inboxes") + }, + }, + { + name: "filter with include_contact_inboxes false", + method: http.MethodPost, + path: fmt.Sprintf("/api/v1/accounts/%d/contacts/filter?include_contact_inboxes=false&page=1&sort=name", s.account.ID), + body: []byte(`{"payload":[{"attribute_key":"email","filter_operator":"contains","values":["leads"],"query_operator":null}]}`), + check: func(resp map[string]any) { + s.Contains(resp, "payload") + s.NotContains(resp["payload"].([]any)[0].(map[string]any), "contact_inboxes") + }, + }, + { + name: "contact conversations", + method: http.MethodGet, + path: fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/conversations", s.account.ID, s.contact.ID), + check: func(resp map[string]any) { + payload := resp["payload"].([]any) + s.Len(payload, 1) + s.Equal(float64(conversation.ID), payload[0].(map[string]any)["id"]) + }, + }, + { + name: "contactable inboxes alias", + method: http.MethodGet, + path: fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/contactable_inboxes", s.account.ID, s.contact.ID), + check: func(resp map[string]any) { + payload := resp["payload"].([]any) + s.Len(payload, 1) + item := payload[0].(map[string]any) + s.Equal("frontend-source", item["source_id"]) + s.Contains(item, "inbox") + }, + }, + { + name: "get contact labels", + method: http.MethodGet, + path: fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/labels", s.account.ID, s.contact.ID), + check: func(resp map[string]any) { + s.ElementsMatch([]any{"customer-support"}, resp["payload"].([]any)) + }, + }, + { + name: "update contact labels", + method: http.MethodPost, + path: fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/labels", s.account.ID, s.contact.ID), + body: []byte(`{"labels":["support-query"]}`), + check: func(resp map[string]any) { + s.ElementsMatch([]any{"support-query"}, resp["payload"].([]any)) + }, + }, + { + name: "destroy selected custom attributes", + method: http.MethodPost, + path: fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/destroy_custom_attributes", s.account.ID, s.contact.ID), + body: []byte(`{"custom_attributes":["cloudCustomer"]}`), + check: func(resp map[string]any) { + payload := resp["payload"].(map[string]any) + attrs := payload["custom_attributes"].(map[string]any) + s.NotContains(attrs, "cloudCustomer") + s.Equal("gold", attrs["tier"]) + }, + }, + { + name: "destroy avatar", + method: http.MethodDelete, + path: fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/avatar", s.account.ID, s.contact.ID), + check: func(resp map[string]any) { + payload := resp["payload"].(map[string]any) + s.Empty(payload["avatar_url"]) + s.Empty(payload["thumbnail"]) + }, + }, + } + + for _, tc := range cases { + s.Run(tc.name, func() { + w := httptest.NewRecorder() + var body *bytes.Reader + if tc.body == nil { + body = bytes.NewReader(nil) + } else { + body = bytes.NewReader(tc.body) + } + req, _ := http.NewRequest(tc.method, tc.path, body) + if tc.body != nil { + req.Header.Set("Content-Type", "application/json") + } + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code, w.Body.String()) + resp := decodeContactTestObject(s.T(), w.Body.Bytes()) + tc.check(resp) + }) + } + + missingFile := httptest.NewRecorder() + importReq, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/contacts/import", s.account.ID), nil) + s.router.ServeHTTP(missingFile, importReq) + s.Equal(http.StatusUnprocessableEntity, missingFile.Code) + s.Equal("File is blank", decodeContactTestObject(s.T(), missingFile.Body.Bytes())["error"]) +} + func (s *ContactHandlerCRUDTestSuite) TestActive_ChatwootPayloadAndPageSize() { now := time.Now().Unix() for i := 0; i < 16; i++ { @@ -669,6 +822,58 @@ func (s *ContactHandlerCRUDTestSuite) TestUpdate_Success() { s.Equal("jane.updated@example.com", payload["email"]) } +func (s *ContactHandlerCRUDTestSuite) TestUpdatePublishesChatwootContactUpdatedEvent() { + hub := &captureAccountHub{} + s.handler.WithEventPublisher(ws.NewEventPublisherLocal(hub, nil)) + + body := `{"name":"Realtime Jane","email":"realtime@example.com","phone_number":"+1555010101","custom_attributes":{"tier":"gold"}}` + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPatch, fmt.Sprintf("/api/v1/accounts/%d/contacts/%d", s.account.ID, s.contact.ID), strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code) + s.Require().NotNil(hub.accountData) + s.Equal(s.account.ID, hub.accountID) + + var event ws.WSMessage + s.Require().NoError(json.Unmarshal(hub.accountData, &event)) + s.Equal(ws.EventContactUpdated, event.Event) + s.Equal(s.account.ID, event.AccountID) + data := event.Data.(map[string]interface{}) + s.Equal(float64(s.contact.ID), data["id"]) + s.Equal("Realtime Jane", data["name"]) + s.Equal("realtime@example.com", data["email"]) + s.Equal("+1555010101", data["phone_number"]) + s.Contains(data, "additional_attributes") + s.Contains(data, "custom_attributes") + s.Contains(data, "availability_status") + s.Contains(data, "contact_inboxes") +} + +type captureAccountHub struct { + accountID uint + accountData []byte + accountIDs []uint + accountDatas [][]byte +} + +func (h *captureAccountHub) SendToAccount(accountID uint, data []byte) { + h.accountID = accountID + h.accountData = append([]byte(nil), data...) + h.accountIDs = append(h.accountIDs, accountID) + h.accountDatas = append(h.accountDatas, append([]byte(nil), data...)) +} + +func (h *captureAccountHub) SendToRoom(_ string, _ []byte) {} + +func (h *captureAccountHub) eventAt(s *ContactHandlerCRUDTestSuite, index int) ws.WSMessage { + s.Require().Greater(len(h.accountDatas), index) + var event ws.WSMessage + s.Require().NoError(json.Unmarshal(h.accountDatas[index], &event)) + return event +} + func (s *ContactHandlerCRUDTestSuite) TestUpdate_AcceptsChatwootPhoneNumberParam() { body := map[string]interface{}{ "phone_number": "+19998887777", @@ -841,6 +1046,27 @@ func (s *ContactHandlerCRUDTestSuite) TestDelete_Success() { s.Error(err, "contact should be soft-deleted") } +func (s *ContactHandlerCRUDTestSuite) TestDeletePublishesChatwootContactDeletedEvent() { + hub := &captureAccountHub{} + s.handler.WithEventPublisher(ws.NewEventPublisherLocal(hub, nil)) + delContact := &model.Contact{AccountID: s.account.ID, Name: "Realtime Delete", Email: "delete@example.com"} + s.Require().NoError(s.db.Create(delContact).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d", s.account.ID, delContact.ID), nil) + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code) + s.Require().Len(hub.accountDatas, 1) + event := hub.eventAt(s, 0) + s.Equal(ws.EventContactDeleted, event.Event) + s.Equal(s.account.ID, event.AccountID) + data := event.Data.(map[string]interface{}) + s.Equal(float64(delContact.ID), data["id"]) + s.Equal("Realtime Delete", data["name"]) + s.Equal("delete@example.com", data["email"]) +} + func (s *ContactHandlerCRUDTestSuite) TestDeleteAvatar_Success() { s.Require().NoError(s.db.Model(s.contact).Update("avatar_url", "https://example.com/avatar.png").Error) @@ -935,6 +1161,99 @@ func (s *ContactHandlerCRUDTestSuite) TestListAttachmentsUsesChatwootFixedPageSi s.Equal(float64(displayID), first["conversation_id"]) } +func (s *ContactHandlerCRUDTestSuite) TestListAttachmentsTimelineDepthMatchesChatwootFrontend() { + contact := &model.Contact{AccountID: s.account.ID, Name: "Timeline Contact", Email: "timeline@example.com", AvatarURL: "https://cdn.example.com/timeline-avatar.png"} + s.Require().NoError(s.db.Create(contact).Error) + otherContact := &model.Contact{AccountID: s.account.ID, Name: "Other Timeline Contact", Email: "other-timeline@example.com"} + s.Require().NoError(s.db.Create(otherContact).Error) + otherAccount := &model.Account{Name: "Other Attachment Account", Locale: "en", Active: true} + s.Require().NoError(s.db.Create(otherAccount).Error) + otherAccountContact := &model.Contact{AccountID: otherAccount.ID, Name: "Other Account Contact", Email: "other-account-timeline@example.com"} + s.Require().NoError(s.db.Create(otherAccountContact).Error) + + inbox := &model.Inbox{AccountID: s.account.ID, Name: "Timeline Files", ChannelType: "web_widget"} + s.Require().NoError(s.db.Create(inbox).Error) + otherInbox := &model.Inbox{AccountID: otherAccount.ID, Name: "Other Account Timeline Files", ChannelType: "web_widget"} + s.Require().NoError(s.db.Create(otherInbox).Error) + + displayID := uint(314) + conversationWithDisplayID := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"} + s.Require().NoError(s.db.Create(conversationWithDisplayID).Error) + conversationWithoutDisplayID := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"} + s.Require().NoError(s.db.Create(conversationWithoutDisplayID).Error) + otherContactConversation := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: otherContact.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"} + s.Require().NoError(s.db.Create(otherContactConversation).Error) + otherAccountConversation := &model.Conversation{AccountID: otherAccount.ID, InboxID: otherInbox.ID, ContactID: otherAccountContact.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"} + s.Require().NoError(s.db.Create(otherAccountConversation).Error) + + baseTime := time.Now().Add(-2 * time.Hour) + contactSenderID := contact.ID + agentSenderID := s.user.ID + oldMessage := &model.Message{Base: model.Base{CreatedAt: baseTime, UpdatedAt: baseTime}, AccountID: s.account.ID, InboxID: inbox.ID, ConversationID: conversationWithDisplayID.ID, SenderID: &contactSenderID, SenderType: "contact", Content: "old image", MessageType: "incoming", ContentType: "text", Status: "sent"} + s.Require().NoError(s.db.Create(oldMessage).Error) + newMessage := &model.Message{Base: model.Base{CreatedAt: baseTime.Add(time.Minute), UpdatedAt: baseTime.Add(time.Minute)}, AccountID: s.account.ID, InboxID: inbox.ID, ConversationID: conversationWithoutDisplayID.ID, SenderID: &agentSenderID, SenderType: "user", Content: "new file", MessageType: "outgoing", ContentType: "text", Status: "sent"} + s.Require().NoError(s.db.Create(newMessage).Error) + otherContactSenderID := otherContact.ID + otherContactMessage := &model.Message{AccountID: s.account.ID, InboxID: inbox.ID, ConversationID: otherContactConversation.ID, SenderID: &otherContactSenderID, SenderType: "contact", Content: "excluded contact", MessageType: "incoming", ContentType: "text", Status: "sent"} + s.Require().NoError(s.db.Create(otherContactMessage).Error) + otherAccountSenderID := otherAccountContact.ID + otherAccountMessage := &model.Message{AccountID: otherAccount.ID, InboxID: otherInbox.ID, ConversationID: otherAccountConversation.ID, SenderID: &otherAccountSenderID, SenderType: "contact", Content: "excluded account", MessageType: "incoming", ContentType: "text", Status: "sent"} + s.Require().NoError(s.db.Create(otherAccountMessage).Error) + + oldAttachmentTime := baseTime.Add(30 * time.Second) + oldAttachment := &model.Attachment{Base: model.Base{CreatedAt: oldAttachmentTime, UpdatedAt: oldAttachmentTime}, AccountID: s.account.ID, MessageID: oldMessage.ID, FileType: "image", FileURL: "https://cdn.example.com/old-image.png", ThumbURL: "https://cdn.example.com/old-thumb.png", FileName: "old-image.png", FileSize: 2048, Width: 800, Height: 600} + s.Require().NoError(s.db.Create(oldAttachment).Error) + newAttachmentTime := baseTime.Add(2 * time.Minute) + newAttachment := &model.Attachment{Base: model.Base{CreatedAt: newAttachmentTime, UpdatedAt: newAttachmentTime}, AccountID: s.account.ID, MessageID: newMessage.ID, FileType: "file", ExternalURL: "https://files.example.com/new-report.pdf", FileName: "new-report.pdf", FileSize: 4096} + s.Require().NoError(s.db.Create(newAttachment).Error) + excludedSameAccount := &model.Attachment{AccountID: s.account.ID, MessageID: otherContactMessage.ID, FileType: "file", FileURL: "https://cdn.example.com/excluded-contact.txt", FileName: "excluded-contact.txt"} + s.Require().NoError(s.db.Create(excludedSameAccount).Error) + excludedOtherAccount := &model.Attachment{AccountID: otherAccount.ID, MessageID: otherAccountMessage.ID, FileType: "file", FileURL: "https://cdn.example.com/excluded-account.txt", FileName: "excluded-account.txt"} + s.Require().NoError(s.db.Create(excludedOtherAccount).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/attachments", s.account.ID, contact.ID), nil) + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code, w.Body.String()) + var resp map[string]any + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + s.Equal(float64(2), resp["meta"].(map[string]any)["total_count"]) + payload := resp["payload"].([]any) + s.Len(payload, 2) + + first := payload[0].(map[string]any) + s.Equal(float64(newAttachment.ID), first["id"]) + s.Equal(float64(newMessage.ID), first["message_id"]) + s.Equal(float64(conversationWithoutDisplayID.ID), first["conversation_id"]) + s.Equal("file", first["file_type"]) + s.Equal("https://files.example.com/new-report.pdf", first["data_url"]) + s.Equal("pdf", first["extension"]) + s.Equal(float64(4096), first["file_size"]) + firstSender := first["sender"].(map[string]any) + s.Equal(float64(s.user.ID), firstSender["id"]) + s.Equal("CRUDTestUser", firstSender["name"]) + s.Equal("agent", firstSender["role"]) + s.Equal(float64(newMessage.CreatedAt.Unix()), first["created_at"]) + + second := payload[1].(map[string]any) + s.Equal(float64(oldAttachment.ID), second["id"]) + s.Equal(float64(oldMessage.ID), second["message_id"]) + s.Equal(float64(displayID), second["conversation_id"]) + s.Equal("image", second["file_type"]) + s.Equal("https://cdn.example.com/old-image.png", second["data_url"]) + s.Equal("https://cdn.example.com/old-thumb.png", second["thumb_url"]) + s.Equal("png", second["extension"]) + s.Equal(float64(2048), second["file_size"]) + s.Equal(float64(800), second["width"]) + s.Equal(float64(600), second["height"]) + secondSender := second["sender"].(map[string]any) + s.Equal(float64(contact.ID), secondSender["id"]) + s.Equal("Timeline Contact", secondSender["name"]) + s.Equal("https://cdn.example.com/timeline-avatar.png", secondSender["thumbnail"]) + s.Equal(float64(oldMessage.CreatedAt.Unix()), second["created_at"]) +} + func (s *ContactHandlerCRUDTestSuite) TestMerge_ChatwootActionsPathReturnsRawContact() { base := &model.Contact{AccountID: s.account.ID, Name: "Base Contact", Email: "base@example.com"} mergee := &model.Contact{AccountID: s.account.ID, Name: "Mergee Contact", PhoneNumber: "+12212345"} @@ -970,6 +1289,37 @@ func (s *ContactHandlerCRUDTestSuite) TestMerge_ChatwootActionsPathReturnsRawCon s.Equal(int64(1), count) } +func (s *ContactHandlerCRUDTestSuite) TestMergePublishesBaseUpdateAndMergeeDeleteEvents() { + hub := &captureAccountHub{} + s.handler.WithEventPublisher(ws.NewEventPublisherLocal(hub, nil)) + base := &model.Contact{AccountID: s.account.ID, Name: "Merge Base", Email: "merge-base@example.com"} + mergee := &model.Contact{AccountID: s.account.ID, Name: "Merge Child", PhoneNumber: "+12215550123"} + s.Require().NoError(s.db.Create(base).Error) + s.Require().NoError(s.db.Create(mergee).Error) + + bodyBytes, _ := json.Marshal(map[string]uint{"base_contact_id": base.ID, "mergee_contact_id": mergee.ID}) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/actions/contact_merge", s.account.ID), bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code) + s.Require().Len(hub.accountDatas, 2) + updatedEvent := hub.eventAt(s, 0) + s.Equal(ws.EventContactUpdated, updatedEvent.Event) + s.Equal(s.account.ID, updatedEvent.AccountID) + updated := updatedEvent.Data.(map[string]interface{}) + s.Equal(float64(base.ID), updated["id"]) + s.Equal("Merge Base", updated["name"]) + s.Equal("+12215550123", updated["phone_number"]) + + deletedEvent := hub.eventAt(s, 1) + s.Equal(ws.EventContactDeleted, deletedEvent.Event) + s.Equal(s.account.ID, deletedEvent.AccountID) + deleted := deletedEvent.Data.(map[string]interface{}) + s.Equal(float64(mergee.ID), deleted["id"]) +} + func (s *ContactHandlerCRUDTestSuite) TestImport_CreatesDataImportAndReturnsOK() { s.Require().NoError(s.db.Create(&model.Tag{AccountID: s.account.ID, Name: "vip"}).Error) @@ -1063,6 +1413,95 @@ func (s *ContactHandlerCRUDTestSuite) TestDownloadExport_ReturnsPersistedCSVArti s.Contains(w.Body.String(), "exported@example.com") } +func (s *ContactHandlerCRUDTestSuite) TestChatwootFrontendContactMergeImportExportContracts() { + base := &model.Contact{AccountID: s.account.ID, Name: "CRM Contract Base", Email: "crm-contract-base@example.com", CustomAttributes: datatypes.JSON(`{"plan":"pro"}`)} + mergee := &model.Contact{AccountID: s.account.ID, Name: "CRM Contract Mergee", PhoneNumber: "+15550123456", CustomAttributes: datatypes.JSON(`{"region":"emea","plan":"free"}`)} + s.Require().NoError(s.db.Create(base).Error) + s.Require().NoError(s.db.Create(mergee).Error) + s.Require().NoError(s.db.Create(&model.Conversation{AccountID: s.account.ID, InboxID: 1, ContactID: mergee.ID, Status: "open", ChannelType: "Channel::WebWidget", Channel: "web_widget"}).Error) + s.Require().NoError(s.db.Create(&model.ContactInbox{ContactID: mergee.ID, InboxID: 1, SourceID: "mergee-source", PubsubToken: "mergee-token"}).Error) + s.Require().NoError(s.db.Create(&model.Note{AccountID: s.account.ID, ContactID: mergee.ID, Content: "mergee note", UserID: &s.user.ID}).Error) + + bodyBytes, _ := json.Marshal(map[string]uint{"base_contact_id": base.ID, "mergee_contact_id": mergee.ID}) + merge := httptest.NewRecorder() + mergeReq, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/actions/contact_merge", s.account.ID), bytes.NewReader(bodyBytes)) + mergeReq.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(merge, mergeReq) + + s.Equal(http.StatusOK, merge.Code) + mergedPayload := decodeContactTestObject(s.T(), merge.Body.Bytes()) + assertChatwootContactRawFixtureShape(s.T(), mergedPayload) + s.Equal(float64(base.ID), mergedPayload["id"]) + s.Equal("crm-contract-base@example.com", mergedPayload["email"]) + s.Equal("+15550123456", mergedPayload["phone_number"]) + s.NotContains(mergedPayload, "payload") + s.NotContains(mergedPayload, "success") + customAttributes := mergedPayload["custom_attributes"].(map[string]any) + s.Equal("pro", customAttributes["plan"]) + s.Equal("emea", customAttributes["region"]) + + var count int64 + s.db.Model(&model.Contact{}).Where("id = ?", mergee.ID).Count(&count) + s.Equal(int64(0), count) + s.db.Model(&model.Conversation{}).Where("contact_id = ?", base.ID).Count(&count) + s.Equal(int64(1), count) + s.db.Model(&model.ContactInbox{}).Where("contact_id = ?", base.ID).Count(&count) + s.Equal(int64(1), count) + s.db.Model(&model.Note{}).Where("contact_id = ?", base.ID).Count(&count) + s.Equal(int64(1), count) + s.Require().NoError(s.db.Create(&model.Tag{AccountID: s.account.ID, Name: "contract-vip"}).Error) + + importBody := &bytes.Buffer{} + writer := multipart.NewWriter(importBody) + part, err := writer.CreateFormFile("import_file", "contacts.csv") + s.Require().NoError(err) + _, err = part.Write([]byte("name,email,phone_number,labels\nContract Imported,contract-imported@example.com,+15550009999,contract-vip\n")) + s.Require().NoError(err) + s.Require().NoError(writer.Close()) + + importResp := httptest.NewRecorder() + importReq, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/import", s.account.ID), importBody) + importReq.Header.Set("Content-Type", writer.FormDataContentType()) + s.router.ServeHTTP(importResp, importReq) + s.Equal(http.StatusOK, importResp.Code) + s.Empty(importResp.Body.String()) + + var imported model.Contact + s.Require().NoError(s.db.Where("account_id = ? AND email = ?", s.account.ID, "contract-imported@example.com").First(&imported).Error) + s.Equal("Contract Imported", imported.Name) + s.Equal("+15550009999", imported.PhoneNumber) + var dataImport model.DataImport + s.Require().NoError(s.db.Where("account_id = ? AND data_type = ?", s.account.ID, "contacts").Order("id DESC").First(&dataImport).Error) + s.Equal(string(model.DataImportStatusCompleted), dataImport.Status) + s.Equal(1, dataImport.TotalRecords) + s.Equal(1, dataImport.ProcessedRecords) + + exportBody, _ := json.Marshal(map[string]any{"column_names": []string{"name", "email", "phone_number"}, "q": "Contract Imported"}) + exportResp := httptest.NewRecorder() + exportReq, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/export", s.account.ID), bytes.NewReader(exportBody)) + exportReq.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(exportResp, exportReq) + s.Equal(http.StatusOK, exportResp.Code) + s.Empty(exportResp.Body.String()) + + var export model.ContactExport + s.Require().NoError(s.db.Where("account_id = ?", s.account.ID).Order("id DESC").First(&export).Error) + s.Equal(string(model.DataImportStatusCompleted), export.Status) + s.Contains(export.FileName, "contacts.csv") + s.Equal("text/csv", export.ContentType) + s.Contains(string(export.CSVData), "name,email,phone_number") + s.Contains(string(export.CSVData), "Contract Imported,contract-imported@example.com,+15550009999") + s.Contains(export.FileURL, fmt.Sprintf("/contacts/export/%d/download", export.ID)) + + downloadResp := httptest.NewRecorder() + downloadReq, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/contacts/export/%d/download", s.account.ID, export.ID), nil) + s.router.ServeHTTP(downloadResp, downloadReq) + s.Equal(http.StatusOK, downloadResp.Code) + s.Equal("text/csv", downloadResp.Header().Get("Content-Type")) + s.Contains(downloadResp.Header().Get("Content-Disposition"), "contacts.csv") + assertContactCSVFixtureShape(s.T(), downloadResp.Body.String(), []string{"name", "email", "phone_number"}) +} + func (s *ContactHandlerCRUDTestSuite) TestLabels_UpdateListAndFilter() { bodyBytes, _ := json.Marshal(map[string]interface{}{"labels": []string{"vip", "trial"}}) @@ -1908,6 +2347,45 @@ func (s *ContactHandlerCRUDTestSuite) TestCreateNote_NotFoundContact() { s.Equal(http.StatusUnprocessableEntity, w.Code) } +func decodeContactTestObject(t *testing.T, body []byte) map[string]interface{} { + t.Helper() + payload := map[string]interface{}{} + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("expected JSON object: %v\n%s", err, string(body)) + } + return payload +} + +func assertChatwootContactRawFixtureShape(t *testing.T, contact map[string]interface{}) { + t.Helper() + for _, key := range []string{"additional_attributes", "availability_status", "blocked", "custom_attributes", "email", "id", "identifier", "name", "phone_number", "thumbnail"} { + if _, ok := contact[key]; !ok { + t.Fatalf("expected raw contact payload to include %q, got %#v", key, contact) + } + } +} + +func assertContactCSVFixtureShape(t *testing.T, body string, expectedHeaders []string) { + t.Helper() + reader := csv.NewReader(strings.NewReader(strings.TrimPrefix(body, "\ufeff"))) + reader.FieldsPerRecord = -1 + rows, err := reader.ReadAll() + if err != nil { + t.Fatalf("failed to read contact CSV: %v\n%s", err, body) + } + if len(rows) < 2 { + t.Fatalf("expected contact CSV header and data rows, got %#v", rows) + } + if len(rows[0]) != len(expectedHeaders) { + t.Fatalf("expected contact CSV headers %#v, got %#v", expectedHeaders, rows[0]) + } + for idx, expected := range expectedHeaders { + if rows[0][idx] != expected { + t.Fatalf("expected contact CSV headers %#v, got %#v", expectedHeaders, rows[0]) + } + } +} + // =========================== // Run the suite // =========================== diff --git a/internal/handler/api/v1/conversation_handler.go b/internal/handler/api/v1/conversation_handler.go index c784ad99..6ec1a924 100644 --- a/internal/handler/api/v1/conversation_handler.go +++ b/internal/handler/api/v1/conversation_handler.go @@ -450,7 +450,28 @@ func (h *ConversationHandler) UpdateLabels(c *gin.Context) { handleServiceError(c, svcErr) return } - c.JSON(http.StatusOK, serializeConversation(h.requestContext(c), h.conversationSvc.DB(), conversation)) + c.JSON(http.StatusOK, gin.H{"payload": gin.H{"conversationId": strconv.FormatUint(uint64(conversation.ID), 10), "labels": labelList(conversation.Labels)}}) +} + +// GetLabels returns the labels assigned to a conversation. +// GET /api/v1/accounts/:account_id/conversations/:conversation_id/labels +// Reference: Chatwoot dashboard conversationLabels store expects { payload: [] }. +func (h *ConversationHandler) GetLabels(c *gin.Context) { + accountID, err := parseUintParam(c, "account_id") + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") + return + } + conversationID, err := parseUintParam(c, "conversation_id") + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation id") + return + } + conversation, ok := h.resolveConversationRoute(c, accountID, conversationID) + if !ok { + return + } + c.JSON(http.StatusOK, gin.H{"payload": labelList(conversation.Labels)}) } // @Summary Search conversations @@ -993,26 +1014,31 @@ func (h *ConversationHandler) AssignTeam(c *gin.Context) { } var req struct { - AgentID *uint `json:"agent_id"` - TeamID *uint `json:"team_id"` + AgentID *uint `json:"agent_id"` + AssigneeID *uint `json:"assignee_id"` + TeamID *uint `json:"team_id"` } if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } + agentID := req.AgentID + if agentID == nil { + agentID = req.AssigneeID + } conversation, ok := h.resolveConversationRoute(c, accountID, conversationID) if !ok { return } - conversation, svcErr := h.conversationSvc.AssignTeam(c.Request.Context(), accountID, conversation.ID, req.AgentID, req.TeamID) + conversation, svcErr := h.conversationSvc.AssignTeam(c.Request.Context(), accountID, conversation.ID, agentID, req.TeamID) if svcErr != nil { handleServiceError(c, svcErr) return } - if req.AgentID != nil { - c.JSON(http.StatusOK, serializeUserFromDB(c.Request.Context(), h.conversationSvc.DB(), *req.AgentID, accountID)) + if agentID != nil { + c.JSON(http.StatusOK, serializeUserFromDB(c.Request.Context(), h.conversationSvc.DB(), *agentID, accountID)) return } if req.TeamID != nil { @@ -1034,7 +1060,7 @@ func handleServiceError(c *gin.Context, err error) { response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, errMsg) return } - if strings.Contains(lower, "invalid") || strings.Contains(lower, "validation") || strings.Contains(lower, "required") { + if strings.Contains(lower, "invalid") || strings.Contains(lower, "validation") || strings.Contains(lower, "required") || strings.Contains(lower, "unsupported file type") || strings.Contains(lower, "mime type") || strings.Contains(lower, "file size") { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, errMsg) return } diff --git a/internal/handler/api/v1/conversation_handler_crud_test.go b/internal/handler/api/v1/conversation_handler_crud_test.go index 4ca1c192..021c6614 100644 --- a/internal/handler/api/v1/conversation_handler_crud_test.go +++ b/internal/handler/api/v1/conversation_handler_crud_test.go @@ -129,6 +129,7 @@ func (s *ConversationCrudTestSuite) SetupSuite() { conversations.POST("/:conversation_id/mute", handler.Mute) conversations.POST("/:conversation_id/unmute", handler.Unmute) conversations.POST("/:conversation_id/labels", handler.UpdateLabels) + conversations.GET("/:conversation_id/labels", handler.GetLabels) conversations.GET("/search", handler.Search) conversations.POST("/filter", handler.Filter) conversations.POST("/:conversation_id/priority", handler.UpdatePriority) @@ -206,6 +207,30 @@ func (s *ConversationCrudTestSuite) convURL(id uint) string { // ========== List Handler Tests ========== func (s *ConversationCrudTestSuite) TestList_Success() { + now := time.Now().UTC().Unix() + s.Require().NoError(s.db.Model(s.testContact).Updates(map[string]any{ + "email": "crud-contact@example.com", + "phone_number": "+1555010101", + "identifier": "visitor-4242", + "additional_attributes": datatypes.JSON([]byte(`{"browser_language":"en"}`)), + }).Error) + displayID := uint(4242) + s.Require().NoError(s.db.Model(s.testConv).Updates(map[string]any{ + "display_id": displayID, + "labels": `["vip","billing"]`, + "additional_attributes": datatypes.JSON([]byte(`{"browser_language":"en"}`)), + "custom_attributes": datatypes.JSON([]byte(`{"plan":"enterprise"}`)), + "last_activity_at": now, + "waiting_since": now - 60, + "first_reply_created_at": now - 30, + "priority": "high", + }).Error) + messageCreatedAt := time.Now().UTC().Add(-5 * time.Minute) + message := &model.Message{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ConversationID: s.testConv.ID, Content: "latest customer reply", MessageType: "incoming", ContentType: "text", Status: "sent", SourceID: "source-message-1", ContentAttributes: datatypes.JSON([]byte(`{"submitted_values":[]}`))} + message.CreatedAt = messageCreatedAt + message.UpdatedAt = messageCreatedAt + s.Require().NoError(s.db.Create(message).Error) + w := httptest.NewRecorder() req, _ := http.NewRequest("GET", s.accountURL()+"/conversations", nil) s.router.ServeHTTP(w, req) @@ -224,7 +249,34 @@ func (s *ConversationCrudTestSuite) TestList_Success() { assert.NoError(s.T(), err) assert.Equal(s.T(), int64(1), resp.Data.Meta.AllCount) assert.Len(s.T(), resp.Data.Payload, 1) - assert.NotNil(s.T(), resp.Data.Payload[0]["meta"]) + payload := resp.Data.Payload[0] + assert.Equal(s.T(), float64(displayID), payload["id"]) + assert.Equal(s.T(), float64(s.testAccount.ID), payload["account_id"]) + assert.Equal(s.T(), float64(s.testInbox.ID), payload["inbox_id"]) + assert.Equal(s.T(), "open", payload["status"]) + assert.Equal(s.T(), "high", payload["priority"]) + assert.Equal(s.T(), []interface{}{"vip", "billing"}, payload["labels"]) + assert.Equal(s.T(), map[string]interface{}{"browser_language": "en"}, payload["additional_attributes"]) + assert.Equal(s.T(), map[string]interface{}{"plan": "enterprise"}, payload["custom_attributes"]) + assert.Equal(s.T(), float64(now), payload["last_activity_at"]) + assert.Equal(s.T(), float64(now-60), payload["waiting_since"]) + assert.Equal(s.T(), float64(now-30), payload["first_reply_created_at"]) + assert.NotContains(s.T(), payload, "success") + meta := payload["meta"].(map[string]interface{}) + assert.Equal(s.T(), "web_widget", meta["channel"]) + sender := meta["sender"].(map[string]interface{}) + assert.Equal(s.T(), "CrudTestContact", sender["name"]) + assert.Equal(s.T(), "crud-contact@example.com", sender["email"]) + messages := payload["messages"].([]interface{}) + s.Require().Len(messages, 1) + latest := messages[0].(map[string]interface{}) + assert.Equal(s.T(), "latest customer reply", latest["content"]) + assert.Equal(s.T(), float64(0), latest["message_type"]) + assert.Equal(s.T(), float64(displayID), latest["conversation_id"]) + contentAttrs := latest["content_attributes"].(map[string]interface{}) + assert.Equal(s.T(), []interface{}{}, contentAttrs["submitted_values"]) + lastNonActivity := payload["last_non_activity_message"].(map[string]interface{}) + assert.Equal(s.T(), latest["id"], lastNonActivity["id"]) } func (s *ConversationCrudTestSuite) TestList_WithStatusFilter() { @@ -919,7 +971,35 @@ func (s *ConversationCrudTestSuite) TestUpdateLabels_Success() { var resp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) - assert.ElementsMatch(s.T(), []interface{}{"support", "bug"}, resp["labels"]) + payload := resp["payload"].(map[string]interface{}) + assert.Equal(s.T(), strconv.FormatUint(uint64(s.testConv.ID), 10), payload["conversationId"]) + assert.ElementsMatch(s.T(), []interface{}{"support", "bug"}, payload["labels"]) +} + +func (s *ConversationCrudTestSuite) TestChatwootFrontendConversationLabelsRuntimeRoutes() { + body, _ := json.Marshal(map[string]interface{}{ + "labels": []string{"customer-success", "on-hold"}, + }) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", s.convURL(s.testConv.ID)+"/labels", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String()) + var updateResp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &updateResp)) + updatePayload := updateResp["payload"].(map[string]interface{}) + assert.Equal(s.T(), strconv.FormatUint(uint64(s.testConv.ID), 10), updatePayload["conversationId"]) + assert.ElementsMatch(s.T(), []interface{}{"customer-success", "on-hold"}, updatePayload["labels"]) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", s.convURL(s.testConv.ID)+"/labels", nil) + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String()) + var getResp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &getResp)) + assert.ElementsMatch(s.T(), []interface{}{"customer-success", "on-hold"}, getResp["payload"]) } func (s *ConversationCrudTestSuite) TestUpdateLabels_InvalidAccountID() { @@ -1658,6 +1738,27 @@ func (s *ConversationCrudTestSuite) TestAssignTeam_WithAgentID() { assert.Equal(s.T(), http.StatusOK, w.Code) } +func (s *ConversationCrudTestSuite) TestAssignTeam_WithChatwootAssigneeID() { + user := &model.User{Name: "Assignee User", Email: "assignee@test.com"} + s.Require().NoError(s.db.Create(user).Error) + s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: s.testAccount.ID, UserID: user.ID, Role: "agent"}).Error) + s.Require().NoError(s.db.Create(&model.InboxMember{InboxID: s.testInbox.ID, UserID: user.ID}).Error) + + body, _ := json.Marshal(map[string]interface{}{ + "assignee_id": user.ID, + }) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", s.convURL(s.testConv.ID)+"/assignments", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String()) + var resp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(s.T(), float64(user.ID), resp["id"]) + assert.Equal(s.T(), "Assignee User", resp["name"]) +} + func (s *ConversationCrudTestSuite) TestAssignTeam_InvalidAccountID() { body, _ := json.Marshal(map[string]interface{}{ "team_id": 1, diff --git a/internal/handler/api/v1/conversation_handler_test.go b/internal/handler/api/v1/conversation_handler_test.go index 7bee9997..1d1f04c5 100644 --- a/internal/handler/api/v1/conversation_handler_test.go +++ b/internal/handler/api/v1/conversation_handler_test.go @@ -143,6 +143,7 @@ func (s *ConversationHandlerTestSuite) SetupSuite() { conversations.GET("/:conversation_id/attachments", handler.ListAttachments) conversations.GET("/:conversation_id/reporting_events", handler.ReportingEvents) conversations.POST("/:conversation_id/toggle_typing", handler.ToggleTyping) + conversations.POST("/:conversation_id/toggle_typing_status", handler.ToggleTyping) conversations.POST("/:conversation_id/update_last_seen", handler.UpdateLastSeen) conversations.DELETE("/:conversation_id", handler.Delete) } @@ -316,6 +317,47 @@ func (s *ConversationHandlerTestSuite) TestUnread_Success() { assert.Equal(s.T(), s.testConv.ID, resp.ID) } +func (s *ConversationHandlerTestSuite) TestUnread_DisplayIDRouteSetsLastSeenBeforeIncoming() { + displayID := uint(909) + s.Require().NoError(s.db.Model(s.testConv).Update("display_id", displayID).Error) + incomingCreatedAt := time.Now().Add(-10 * time.Minute) + oldSeen := incomingCreatedAt.Add(5 * time.Minute).Unix() + s.Require().NoError(s.db.Model(s.testConv).Updates(map[string]any{ + "agent_last_seen_at": oldSeen, + "assignee_last_seen_at": oldSeen, + }).Error) + message := &model.Message{ + AccountID: s.testAccount.ID, + InboxID: s.testInbox.ID, + ConversationID: s.testConv.ID, + MessageType: string(model.MessageTypeIncoming), + Content: "unread transition", + ContentType: "text", + SenderType: "contact", + } + s.Require().NoError(s.db.Create(message).Error) + s.Require().NoError(s.db.Model(message).Updates(map[string]any{"created_at": incomingCreatedAt, "updated_at": incomingCreatedAt}).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(displayID), 10)+"/unread", nil) + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String()) + var resp map[string]any + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(s.T(), float64(displayID), resp["id"]) + assert.Equal(s.T(), incomingCreatedAt.Add(-time.Second).Unix(), int64(resp["agent_last_seen_at"].(float64))) + assert.Equal(s.T(), incomingCreatedAt.Add(-time.Second).Unix(), int64(resp["assignee_last_seen_at"].(float64))) + assert.NotContains(s.T(), resp, "success") + + var stored model.Conversation + s.Require().NoError(s.db.First(&stored, s.testConv.ID).Error) + s.Require().NotNil(stored.AgentLastSeenAt) + s.Require().NotNil(stored.AssigneeLastSeenAt) + assert.Equal(s.T(), incomingCreatedAt.Add(-time.Second).Unix(), *stored.AgentLastSeenAt) + assert.Equal(s.T(), incomingCreatedAt.Add(-time.Second).Unix(), *stored.AssigneeLastSeenAt) +} + func (s *ConversationHandlerTestSuite) TestUnread_InvalidAccountID() { w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/invalid/conversations/1/unread", nil) @@ -799,6 +841,15 @@ func (s *ConversationHandlerTestSuite) TestToggleTyping_MissingFieldsIsNoopSucce assert.Empty(s.T(), w.Body.String()) } +func (s *ConversationHandlerTestSuite) TestToggleTypingStatus_ChatwootRouteSuccess() { + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/toggle_typing_status", s.testAccount.ID, s.testConv.ID), bytes.NewBufferString(`{"typing_status":"typing_on","is_private":false}`)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + assert.Equal(s.T(), http.StatusOK, w.Code) + assert.Empty(s.T(), w.Body.String()) +} + func (s *ConversationHandlerTestSuite) TestToggleTyping_EmptyBodyIsNoopSuccess() { w := httptest.NewRecorder() req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/toggle_typing", s.testAccount.ID, s.testConv.ID), nil) @@ -839,6 +890,33 @@ func (s *ConversationHandlerTestSuite) TestUpdateLastSeen_SuccessMarksNotificati assert.NotNil(s.T(), updatedNotification.ReadAt) } +func (s *ConversationHandlerTestSuite) TestUpdateLastSeen_DisplayIDRouteMarksNotificationRead() { + displayID := uint(910) + s.Require().NoError(s.db.Model(s.testConv).Update("display_id", displayID).Error) + oldSeen := time.Now().Add(-2 * time.Hour).Unix() + s.Require().NoError(s.db.Model(s.testConv).Update("agent_last_seen_at", oldSeen).Error) + s.Require().NoError(s.db.Create(&model.Message{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ConversationID: s.testConv.ID, MessageType: string(model.MessageTypeIncoming), Content: "new display-id message"}).Error) + accountID := s.testAccount.ID + notification := &model.Notification{UserID: s.testUser.ID, AccountID: &accountID, NotificationType: "assigned_conversation_new_message", PrimaryActorType: "Conversation", PrimaryActorID: s.testConv.ID} + s.Require().NoError(s.db.Create(notification).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/update_last_seen", s.testAccount.ID, displayID), nil) + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + assert.Empty(s.T(), w.Body.String()) + + var conversation model.Conversation + s.Require().NoError(s.db.First(&conversation, s.testConv.ID).Error) + s.Require().NotNil(conversation.AgentLastSeenAt) + assert.Greater(s.T(), *conversation.AgentLastSeenAt, oldSeen) + + var updatedNotification model.Notification + s.Require().NoError(s.db.First(&updatedNotification, notification.ID).Error) + assert.NotNil(s.T(), updatedNotification.ReadAt) +} + func (s *ConversationHandlerTestSuite) TestUpdateLastSeen_InvalidAccountID() { w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/conversations/1/update_last_seen", nil) diff --git a/internal/handler/api/v1/conversation_participant_handler_test.go b/internal/handler/api/v1/conversation_participant_handler_test.go index 646c7370..37cd5704 100644 --- a/internal/handler/api/v1/conversation_participant_handler_test.go +++ b/internal/handler/api/v1/conversation_participant_handler_test.go @@ -127,6 +127,67 @@ func (s *ConversationParticipantHandlerTestSuite) Test_AddParticipant() { assert.NotContains(s.T(), resp[0], "user_id") } +func (s *ConversationParticipantHandlerTestSuite) Test_DisplayIDRouteEndToEnd() { + displayID := uint(707) + conv := &model.Conversation{AccountID: s.testAccount.ID, DisplayID: &displayID, InboxID: s.testConv.InboxID, ContactID: s.testConv.ContactID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"} + s.Require().NoError(s.db.Create(conv).Error) + initialUser := &model.User{Name: "DisplayInitialUser", Email: "display-initial@test.com", Password: "hashed", Role: "agent", Active: true} + s.Require().NoError(s.db.Create(initialUser).Error) + baseURL := "/api/v1/accounts/" + strconv.FormatUint(uint64(s.testAccount.ID), 10) + + "/conversations/" + strconv.FormatUint(uint64(displayID), 10) + "/participants" + + body, _ := json.Marshal(map[string]interface{}{"user_ids": []uint{initialUser.ID}}) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", baseURL, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var created []map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &created)) + s.Require().Len(created, 1) + assert.Equal(s.T(), float64(initialUser.ID), created[0]["id"]) + assert.Equal(s.T(), initialUser.Email, created[0]["email"]) + assert.NotContains(s.T(), created[0], "conversation_id") + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", baseURL, nil) + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var listed []map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &listed)) + s.Require().Len(listed, 1) + assert.Equal(s.T(), float64(initialUser.ID), listed[0]["id"]) + + replacementUser := &model.User{Name: "DisplayRouteUser", Email: "display-route@test.com", Password: "hashed", Role: "agent", Active: true} + s.Require().NoError(s.db.Create(replacementUser).Error) + body, _ = json.Marshal(map[string]interface{}{"user_ids": []uint{replacementUser.ID}}) + w = httptest.NewRecorder() + req, _ = http.NewRequest("PATCH", baseURL, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var replaced []map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &replaced)) + s.Require().Len(replaced, 1) + assert.Equal(s.T(), float64(replacementUser.ID), replaced[0]["id"]) + + var oldCount int64 + s.Require().NoError(s.db.Model(&model.ConversationParticipant{}).Where("conversation_id = ? AND user_id = ?", conv.ID, initialUser.ID).Count(&oldCount).Error) + assert.Equal(s.T(), int64(0), oldCount) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("DELETE", baseURL+"/"+strconv.FormatUint(uint64(replacementUser.ID), 10), nil) + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var remainingCount int64 + s.Require().NoError(s.db.Model(&model.ConversationParticipant{}).Where("conversation_id = ?", conv.ID).Count(&remainingCount).Error) + assert.Equal(s.T(), int64(0), remainingCount) +} + func (s *ConversationParticipantHandlerTestSuite) Test_ListParticipants() { // First add a participant body := map[string]interface{}{ diff --git a/internal/handler/api/v1/copilot_thread_handler_test.go b/internal/handler/api/v1/copilot_thread_handler_test.go index 9324c8f9..cc3f1262 100644 --- a/internal/handler/api/v1/copilot_thread_handler_test.go +++ b/internal/handler/api/v1/copilot_thread_handler_test.go @@ -2,6 +2,7 @@ package v1 import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" @@ -15,6 +16,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/logger" + "github.com/gochat/gochat/internal/llm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" @@ -33,6 +35,10 @@ type copilotParityFixture struct { } func newCopilotParityFixture(t *testing.T) *copilotParityFixture { + return newCopilotParityFixtureWithProvider(t, nil) +} + +func newCopilotParityFixtureWithProvider(t *testing.T, provider llm.Provider) *copilotParityFixture { t.Helper() gin.SetMode(gin.TestMode) @@ -66,7 +72,7 @@ func newCopilotParityFixture(t *testing.T) *copilotParityFixture { messageRepo := repository.NewCopilotMessageRepo(db) suggestionRepo := repository.NewCopilotSuggestionRepo(db) assistantRepo := repository.NewCaptainAssistantRepo(db) - handler := NewCopilotHandler(service.NewCopilotService(threadRepo, messageRepo, suggestionRepo, nil, assistantRepo)) + handler := NewCopilotHandler(service.NewCopilotService(threadRepo, messageRepo, suggestionRepo, provider, assistantRepo)) fixture := &copilotParityFixture{ db: db, @@ -237,6 +243,40 @@ func TestCopilotThreadMessagesListAndCreateUseNestedPayloads(t *testing.T) { require.Len(t, messages, 4) } +func TestCopilotThreadCreateProviderDisabledReturnsStableUnavailableAssistantMessage(t *testing.T) { + f := newCopilotParityFixture(t) + + thread := f.createThread(t, "Need help") + threadID := uintString(uint(thread["id"].(float64))) + w := f.request(f.router, http.MethodGet, f.captainPath("/copilot_threads/"+threadID+"/copilot_messages/"), nil) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + messages := decodeMap(t, w)["payload"].([]any) + require.Len(t, messages, 2) + assistant := messages[1].(map[string]any) + require.Equal(t, "assistant", assistant["message_type"]) + require.Equal(t, service.CopilotUnavailableMessage, assistant["message"].(map[string]any)["content"]) + require.Nil(t, assistant["success"]) +} + +func TestCopilotThreadCreateProviderEnabledPersistsGeneratedAssistantMessage(t *testing.T) { + provider := &copilotFakeProvider{content: "Generated copilot answer"} + f := newCopilotParityFixtureWithProvider(t, provider) + + thread := f.createThread(t, "Need help") + require.NotEmpty(t, provider.lastRequest.Messages) + require.Equal(t, "system", provider.lastRequest.Messages[0].Role) + require.Equal(t, "Need help", provider.lastRequest.Messages[len(provider.lastRequest.Messages)-1].Content) + + threadID := uintString(uint(thread["id"].(float64))) + w := f.request(f.router, http.MethodGet, f.captainPath("/copilot_threads/"+threadID+"/copilot_messages/"), nil) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + messages := decodeMap(t, w)["payload"].([]any) + require.Len(t, messages, 2) + assistant := messages[1].(map[string]any) + require.Equal(t, "assistant", assistant["message_type"]) + require.Equal(t, "Generated copilot answer", assistant["message"].(map[string]any)["content"]) +} + func TestCopilotMessagePayloadUsesThreadPushShape(t *testing.T) { f := newCopilotParityFixture(t) thread := f.createThread(t, "Need help") @@ -312,3 +352,21 @@ func TestCopilotThreadGetAndDeleteAreScoped(t *testing.T) { w = f.request(f.router, http.MethodGet, path, nil) require.Equal(t, http.StatusNotFound, w.Code, w.Body.String()) } + +type copilotFakeProvider struct { + content string + lastRequest llm.ChatRequest +} + +func (p *copilotFakeProvider) ChatCompletion(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) { + p.lastRequest = req + return &llm.ChatResponse{Choices: []llm.ChatChoice{{Message: llm.ChatMessage{Role: "assistant", Content: p.content}}}}, nil +} + +func (p *copilotFakeProvider) CreateEmbedding(ctx context.Context, req llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) { + return &llm.EmbeddingResponse{}, nil +} + +func (p *copilotFakeProvider) ChatCompletionStream(ctx context.Context, req llm.ChatRequest, onChunk func(llm.StreamChunk) error) error { + return nil +} diff --git a/internal/handler/api/v1/crm_frontend_smoke_test.go b/internal/handler/api/v1/crm_frontend_smoke_test.go index ce24d75e..0efa2b47 100644 --- a/internal/handler/api/v1/crm_frontend_smoke_test.go +++ b/internal/handler/api/v1/crm_frontend_smoke_test.go @@ -152,9 +152,12 @@ func smokeCreateContact(t *testing.T, router *gin.Engine, accountID uint, inboxI data := smokeDecodeObject(t, resp) payload := smokeObject(t, data, "payload") contact := smokeObject(t, payload, "contact") + smokeAssertContactFixtureShape(t, contact, false) require.Equal(t, "Jane Frontend", contact["name"]) require.Equal(t, "jane.frontend@example.com", contact["email"]) - require.NotNil(t, payload["contact_inbox"]) + contactInbox := smokeObject(t, payload, "contact_inbox") + require.Equal(t, "frontend-source", contactInbox["source_id"]) + smokeAssertInboxSlimFixtureShape(t, smokeObject(t, contactInbox, "inbox")) return smokeUint(t, contact["id"]) } @@ -165,10 +168,16 @@ func smokeAssertContactListAndSearch(t *testing.T, router *gin.Engine, accountID resp := smokeJSONRequest(t, router, http.MethodGet, listURL, nil) require.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) data := smokeDecodeObject(t, resp) - require.Equal(t, float64(1), smokeObject(t, data, "meta")["count"]) + meta := smokeObject(t, data, "meta") + require.Equal(t, float64(1), meta["count"]) + require.Equal(t, float64(1), meta["current_page"]) + require.NotContains(t, meta, "total_count") contacts := smokeArray(t, data, "payload") require.Len(t, contacts, 1) - require.Equal(t, float64(contactID), contacts[0].(map[string]any)["id"]) + contact := contacts[0].(map[string]any) + smokeAssertContactFixtureShape(t, contact, false) + require.Equal(t, float64(contactID), contact["id"]) + require.NotContains(t, contact, "contact_inboxes") emptySearch := smokeJSONRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/contacts/search?include_contact_inboxes=false&page=1&sort=name&q=", accountID), nil) require.Equal(t, http.StatusUnprocessableEntity, emptySearch.Code, emptySearch.Body.String()) @@ -188,12 +197,16 @@ func smokeAssertContactShowUpdateLabelsAndNestedData(t *testing.T, router *gin.E showURL := fmt.Sprintf("/api/v1/accounts/%d/contacts/%d?include_contact_inboxes=false", accountID, contactID) show := smokeJSONRequest(t, router, http.MethodGet, showURL, nil) require.Equal(t, http.StatusOK, show.Code, show.Body.String()) - require.Equal(t, "Jane Frontend", smokeObject(t, smokeDecodeObject(t, show), "payload")["name"]) + showPayload := smokeObject(t, smokeDecodeObject(t, show), "payload") + smokeAssertContactFixtureShape(t, showPayload, false) + require.Equal(t, "Jane Frontend", showPayload["name"]) + require.NotContains(t, showPayload, "contact_inboxes") updateBody := map[string]any{"name": "Jane Updated", "custom_attributes": map[string]any{"plan": "pro", "tier": "gold"}} update := smokeJSONRequest(t, router, http.MethodPatch, showURL, updateBody) require.Equal(t, http.StatusOK, update.Code, update.Body.String()) updated := smokeObject(t, smokeDecodeObject(t, update), "payload") + smokeAssertContactFixtureShape(t, updated, false) require.Equal(t, "Jane Updated", updated["name"]) require.Equal(t, "pro", smokeObject(t, updated, "custom_attributes")["plan"]) @@ -217,15 +230,21 @@ func smokeAssertContactShowUpdateLabelsAndNestedData(t *testing.T, router *gin.E contactable := smokeJSONRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/contactable_inboxes", accountID, contactID), nil) require.Equal(t, http.StatusOK, contactable.Code, contactable.Body.String()) - require.Len(t, smokeArray(t, smokeDecodeObject(t, contactable), "payload"), 1) + contactablePayload := smokeArray(t, smokeDecodeObject(t, contactable), "payload") + require.Len(t, contactablePayload, 1) + smokeAssertInboxSlimFixtureShape(t, smokeObject(t, contactablePayload[0].(map[string]any), "inbox")) note := smokeJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/notes", accountID, contactID), map[string]any{"note": map[string]any{"content": "Frontend note"}}) require.Equal(t, http.StatusOK, note.Code, note.Body.String()) - require.Equal(t, "Frontend note", smokeDecodeObject(t, note)["content"]) + notePayload := smokeDecodeObject(t, note) + smokeAssertContactNoteFixtureShape(t, notePayload) + require.Equal(t, "Frontend note", notePayload["content"]) notes := smokeJSONRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/notes", accountID, contactID), nil) require.Equal(t, http.StatusOK, notes.Code, notes.Body.String()) - require.Len(t, smokeDecodeArray(t, notes), 1) + notesPayload := smokeDecodeArray(t, notes) + require.Len(t, notesPayload, 1) + smokeAssertContactNoteFixtureShape(t, notesPayload[0].(map[string]any)) createConversationForSmoke(t, db, accountID, userID, contactID, inboxID) conversations := smokeJSONRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/conversations", accountID, contactID), nil) @@ -244,11 +263,19 @@ func smokeAssertCompanyFlows(t *testing.T, router *gin.Engine, db *gorm.DB, acco "company": map[string]any{"name": "Acme Frontend", "domain": "acme.example", "custom_attributes": map[string]any{"plan": "enterprise", "region": "apac"}}, }) require.Equal(t, http.StatusOK, companyCreate.Code, companyCreate.Body.String()) - companyID := smokeUint(t, smokeObject(t, smokeDecodeObject(t, companyCreate), "payload")["id"]) + createdCompany := smokeObject(t, smokeDecodeObject(t, companyCreate), "payload") + smokeAssertCompanyFixtureShape(t, createdCompany) + companyID := smokeUint(t, createdCompany["id"]) companyList := smokeJSONRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/companies/?page=1&sort=name", accountID), nil) require.Equal(t, http.StatusOK, companyList.Code, companyList.Body.String()) - require.Equal(t, float64(1), smokeObject(t, smokeDecodeObject(t, companyList), "meta")["total_count"]) + companyListData := smokeDecodeObject(t, companyList) + companyMeta := smokeObject(t, companyListData, "meta") + require.Equal(t, float64(1), companyMeta["total_count"]) + require.Equal(t, float64(1), companyMeta["page"]) + listedCompanies := smokeArray(t, companyListData, "payload") + require.Len(t, listedCompanies, 1) + smokeAssertCompanyFixtureShape(t, listedCompanies[0].(map[string]any)) companySearch := smokeJSONRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/companies/search?q=Acme&page=1&sort=name", accountID), nil) require.Equal(t, http.StatusOK, companySearch.Code, companySearch.Body.String()) @@ -260,11 +287,15 @@ func smokeAssertCompanyFlows(t *testing.T, router *gin.Engine, db *gorm.DB, acco companyShowURL := fmt.Sprintf("/api/v1/accounts/%d/companies/%d", accountID, companyID) companyShow := smokeJSONRequest(t, router, http.MethodGet, companyShowURL, nil) require.Equal(t, http.StatusOK, companyShow.Code, companyShow.Body.String()) - require.Equal(t, "Acme Frontend", smokeObject(t, smokeDecodeObject(t, companyShow), "payload")["name"]) + shownCompany := smokeObject(t, smokeDecodeObject(t, companyShow), "payload") + smokeAssertCompanyFixtureShape(t, shownCompany) + require.Equal(t, "Acme Frontend", shownCompany["name"]) companyUpdate := smokeJSONRequest(t, router, http.MethodPatch, companyShowURL, map[string]any{"company": map[string]any{"name": "Acme Updated", "custom_attributes": map[string]any{"segment": "platinum"}}}) require.Equal(t, http.StatusOK, companyUpdate.Code, companyUpdate.Body.String()) - require.Equal(t, "Acme Updated", smokeObject(t, smokeDecodeObject(t, companyUpdate), "payload")["name"]) + updatedCompany := smokeObject(t, smokeDecodeObject(t, companyUpdate), "payload") + smokeAssertCompanyFixtureShape(t, updatedCompany) + require.Equal(t, "Acme Updated", updatedCompany["name"]) destroyCompanyAttrs := smokeJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/companies/%d/destroy_custom_attributes", accountID, companyID), map[string]any{"custom_attributes": []string{"region"}}) require.Equal(t, http.StatusOK, destroyCompanyAttrs.Code, destroyCompanyAttrs.Body.String()) @@ -272,11 +303,17 @@ func smokeAssertCompanyFlows(t *testing.T, router *gin.Engine, db *gorm.DB, acco attach := smokeJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/companies/%d/contacts", accountID, companyID), map[string]any{"contact_id": contactID}) require.Equal(t, http.StatusOK, attach.Code, attach.Body.String()) - require.True(t, smokeObject(t, smokeDecodeObject(t, attach), "payload")["linked_to_current_company"].(bool)) + attachedContact := smokeObject(t, smokeDecodeObject(t, attach), "payload") + smokeAssertCompanyContactFixtureShape(t, attachedContact) + require.True(t, attachedContact["linked_to_current_company"].(bool)) companyContacts := smokeJSONRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/companies/%d/contacts?page=1", accountID, companyID), nil) require.Equal(t, http.StatusOK, companyContacts.Code, companyContacts.Body.String()) - require.Len(t, smokeArray(t, smokeDecodeObject(t, companyContacts), "payload"), 1) + companyContactsData := smokeDecodeObject(t, companyContacts) + require.Equal(t, float64(1), smokeObject(t, companyContactsData, "meta")["total_count"]) + companyContactPayload := smokeArray(t, companyContactsData, "payload") + require.Len(t, companyContactPayload, 1) + smokeAssertCompanyContactFixtureShape(t, companyContactPayload[0].(map[string]any)) candidate := &model.Contact{AccountID: accountID, Name: "Search Candidate", Email: "candidate@example.com"} require.NoError(t, db.Create(candidate).Error) @@ -286,11 +323,15 @@ func smokeAssertCompanyFlows(t *testing.T, router *gin.Engine, db *gorm.DB, acco companyNote := smokeJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/companies/%d/notes", accountID, companyID), map[string]any{"content": "Company frontend note"}) require.Equal(t, http.StatusOK, companyNote.Code, companyNote.Body.String()) - require.Equal(t, "Company frontend note", smokeObject(t, smokeDecodeObject(t, companyNote), "payload")["content"]) + companyNotePayload := smokeObject(t, smokeDecodeObject(t, companyNote), "payload") + smokeAssertCompanyNoteFixtureShape(t, companyNotePayload) + require.Equal(t, "Company frontend note", companyNotePayload["content"]) companyNotes := smokeJSONRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/companies/%d/notes", accountID, companyID), nil) require.Equal(t, http.StatusOK, companyNotes.Code, companyNotes.Body.String()) - require.Len(t, smokeArray(t, smokeDecodeObject(t, companyNotes), "payload"), 1) + companyNotesPayload := smokeArray(t, smokeDecodeObject(t, companyNotes), "payload") + require.Len(t, companyNotesPayload, 1) + smokeAssertCompanyNoteFixtureShape(t, companyNotesPayload[0].(map[string]any)) companyConversations := smokeJSONRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/companies/%d/conversations", accountID, companyID), nil) require.Equal(t, http.StatusOK, companyConversations.Code, companyConversations.Body.String()) @@ -363,6 +404,59 @@ func smokeDecodeArray(t *testing.T, resp *httptest.ResponseRecorder) []any { return data } +func smokeAssertContactFixtureShape(t *testing.T, contact map[string]any, includeInboxes bool) { + t.Helper() + for _, key := range []string{"additional_attributes", "availability_status", "blocked", "custom_attributes", "email", "id", "identifier", "name", "phone_number", "thumbnail"} { + require.Contains(t, contact, key, "contact payload should expose %s", key) + } + require.IsType(t, map[string]any{}, smokeObject(t, contact, "additional_attributes")) + require.IsType(t, map[string]any{}, smokeObject(t, contact, "custom_attributes")) + require.IsType(t, "", contact["availability_status"]) + require.IsType(t, false, contact["blocked"]) + if includeInboxes { + require.Contains(t, contact, "contact_inboxes") + } +} + +func smokeAssertCompanyFixtureShape(t *testing.T, company map[string]any) { + t.Helper() + for _, key := range []string{"additional_attributes", "avatar_url", "contacts_count", "created_at", "custom_attributes", "description", "domain", "id", "last_activity_at", "name", "updated_at"} { + require.Contains(t, company, key, "company payload should expose %s", key) + } + require.IsType(t, map[string]any{}, smokeObject(t, company, "additional_attributes")) + require.IsType(t, map[string]any{}, smokeObject(t, company, "custom_attributes")) +} + +func smokeAssertCompanyContactFixtureShape(t *testing.T, contact map[string]any) { + t.Helper() + smokeAssertContactFixtureShape(t, contact, false) + require.Contains(t, contact, "company") + require.Contains(t, contact, "company_id") + require.Contains(t, contact, "linked_to_current_company") + require.IsType(t, false, contact["linked_to_current_company"]) +} + +func smokeAssertInboxSlimFixtureShape(t *testing.T, inbox map[string]any) { + t.Helper() + for _, key := range []string{"avatar_url", "channel_id", "channel_type", "id", "name", "provider"} { + require.Contains(t, inbox, key, "inbox payload should expose %s", key) + } +} + +func smokeAssertContactNoteFixtureShape(t *testing.T, note map[string]any) { + t.Helper() + for _, key := range []string{"account_id", "contact_id", "content", "created_at", "id", "updated_at", "user_id"} { + require.Contains(t, note, key, "contact note payload should expose %s", key) + } +} + +func smokeAssertCompanyNoteFixtureShape(t *testing.T, note map[string]any) { + t.Helper() + for _, key := range []string{"company_id", "content", "created_at", "id", "updated_at", "user_id"} { + require.Contains(t, note, key, "company note payload should expose %s", key) + } +} + func smokeObject(t *testing.T, data map[string]any, key string) map[string]any { t.Helper() value, ok := data[key].(map[string]any) diff --git a/internal/handler/api/v1/csat_survey_handler_test.go b/internal/handler/api/v1/csat_survey_handler_test.go index 9cb1ff4a..2b44ea5b 100644 --- a/internal/handler/api/v1/csat_survey_handler_test.go +++ b/internal/handler/api/v1/csat_survey_handler_test.go @@ -14,6 +14,8 @@ import ( "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/automation" "github.com/gochat/gochat/internal/model" + "github.com/gochat/gochat/internal/repository" + "github.com/gochat/gochat/internal/service" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "gorm.io/datatypes" @@ -51,19 +53,22 @@ func (s *CsatSurveyHandlerTestSuite) SetupSuite() { &model.Contact{}, &model.Conversation{}, &model.Message{}, + &model.Audit{}, &automation.CsatSurveyResponse{}, &model.ReportingEventsRollup{}, )) s.db = db svc := automation.NewCsatSurveyService(&csatSurveyTestDBProvider{db: db}) - s.handler = NewCsatSurveyHandler(svc) + auditSvc := service.NewAuditService(repository.NewAuditRepo(db)) + s.handler = NewCsatSurveyHandler(svc).WithAuditService(auditSvc) s.account = &model.Account{Name: "test-csat-survey-account"} s.Require().NoError(db.Create(s.account).Error) } func (s *CsatSurveyHandlerTestSuite) SetupTest() { + s.db.Exec("DELETE FROM audits") s.db.Exec("DELETE FROM csat_survey_responses") s.db.Exec("DELETE FROM messages") s.db.Exec("DELETE FROM conversations") @@ -148,6 +153,67 @@ func (s *CsatSurveyHandlerTestSuite) TestList_ChatwootPayloadAndFilters() { assert.Equal(s.T(), reviewer.Name, reviewerPayload["name"]) } +func (s *CsatSurveyHandlerTestSuite) TestCSATFrontendContractShapes() { + createdAt := time.Now().Add(-2 * time.Hour).Truncate(time.Second) + agent, _, _, conversation, _ := s.seedAccountCsatResponseGraph(createdAt, 5) + r := gin.New() + r.GET("/api/v1/accounts/:account_id/csat_survey_responses", s.handler.List) + r.GET("/api/v1/accounts/:account_id/csat_survey_responses/metrics", s.handler.Metrics) + r.GET("/api/v1/accounts/:account_id/csat_survey_responses/download", s.handler.Download) + r.GET("/public/api/v1/csat_survey/:id", s.handler.PublicGet) + r.PATCH("/public/api/v1/csat_survey/:id", s.handler.PublicUpdate) + + listURL := fmt.Sprintf("/api/v1/accounts/%d/csat_survey_responses?page=1&since=%d&until=%d&user_ids=%d&inbox_id=%d&team_id=%d&rating=5&sort=-created_at", + s.account.ID, createdAt.Add(-time.Hour).Unix(), createdAt.Add(time.Hour).Unix(), agent.ID, conversation.InboxID, *conversation.TeamID) + list := httptest.NewRecorder() + reqList, _ := http.NewRequest(http.MethodGet, listURL, nil) + r.ServeHTTP(list, reqList) + assert.Equal(s.T(), http.StatusOK, list.Code) + listPayload := decodeCSATArray(s.T(), list.Body.String()) + s.Require().Len(listPayload, 1) + assertChatwootCSATListItemShape(s.T(), listPayload[0]) + assert.Equal(s.T(), float64(42), listPayload[0]["conversation_id"]) + + metricsURL := fmt.Sprintf("/api/v1/accounts/%d/csat_survey_responses/metrics?since=%d&until=%d&user_ids=%d&inbox_id=%d&team_id=%d&rating=5", + s.account.ID, createdAt.Add(-time.Hour).Unix(), createdAt.Add(time.Hour).Unix(), agent.ID, conversation.InboxID, *conversation.TeamID) + metrics := httptest.NewRecorder() + reqMetrics, _ := http.NewRequest(http.MethodGet, metricsURL, nil) + r.ServeHTTP(metrics, reqMetrics) + assert.Equal(s.T(), http.StatusOK, metrics.Code) + metricsPayload := decodeCSATObject(s.T(), metrics.Body.String()) + assertChatwootCSATMetricsShape(s.T(), metricsPayload) + assert.Equal(s.T(), float64(1), metricsPayload["total_count"]) + + download := httptest.NewRecorder() + reqDownload, _ := http.NewRequest(http.MethodGet, metricsURL[:strings.Index(metricsURL, "/metrics")]+"/download"+metricsURL[strings.Index(metricsURL, "?"):], nil) + reqDownload.Host = "gochat.test" + reqDownload.Header.Set("X-Forwarded-Proto", "https") + r.ServeHTTP(download, reqDownload) + assert.Equal(s.T(), http.StatusOK, download.Code) + assertChatwootCSATCSVShape(s.T(), download, []string{"Agent Name", "Rating", "Feedback Comment", "Contact Name", "Contact Email Address", "Contact Phone Number", "Link to the conversation", "Recorded date", "Review Notes"}) + + publicConversation, _ := s.seedPublicCsatSurvey(createdAt) + show := httptest.NewRecorder() + reqShow, _ := http.NewRequest(http.MethodGet, "/public/api/v1/csat_survey/"+publicConversation.UUID, nil) + r.ServeHTTP(show, reqShow) + assert.Equal(s.T(), http.StatusOK, show.Code) + showPayload := decodeCSATObject(s.T(), show.Body.String()) + assertChatwootPublicCSATShape(s.T(), showPayload) + assert.Nil(s.T(), showPayload["csat_survey_response"]) + + update := httptest.NewRecorder() + body := `{"message":{"submitted_values":{"csat_survey_response":{"rating":4,"feedback_message":"Helpful"}}}}` + reqUpdate, _ := http.NewRequest(http.MethodPatch, "/public/api/v1/csat_survey/"+publicConversation.UUID, bytes.NewBufferString(body)) + reqUpdate.Header.Set("Content-Type", "application/json") + r.ServeHTTP(update, reqUpdate) + assert.Equal(s.T(), http.StatusOK, update.Code) + updatePayload := decodeCSATObject(s.T(), update.Body.String()) + assertChatwootPublicCSATShape(s.T(), updatePayload) + csatResponse := updatePayload["csat_survey_response"].(map[string]any) + assert.Equal(s.T(), float64(4), csatResponse["rating"]) + assert.Equal(s.T(), "Helpful", csatResponse["feedback_message"]) +} + func (s *CsatSurveyHandlerTestSuite) TestList_IgnoresPerPageAndUsesChatwootFixedPageSize() { createdAt := time.Now().Add(-2 * time.Hour).Truncate(time.Second) _, _, contact, conversation, _ := s.seedAccountCsatResponseGraph(createdAt, 5) @@ -226,6 +292,73 @@ func (s *CsatSurveyHandlerTestSuite) TestMetrics_ChatwootPayloadAndFilters() { assert.Equal(s.T(), float64(1), ratings["5"]) } +func (s *CsatSurveyHandlerTestSuite) TestMetrics_DashboardValueDriftFiltersMatchChatwootFrontend() { + baseTime := time.Now().Add(-6 * time.Hour).Truncate(time.Second) + agent, _, contact, conversation, _ := s.seedAccountCsatResponseGraph(baseTime.Add(10*time.Minute), 5) + s.Require().NoError(s.db.Create(&automation.CsatSurveyResponse{ + AccountID: s.account.ID, + ConversationID: conversation.ID, + ContactID: contact.ID, + AssignedAgentID: &agent.ID, + Rating: 4, + FeedbackMessage: "Good enough", + }).Error) + s.Require().NoError(s.db.Model(&automation.CsatSurveyResponse{}).Where("rating = ?", 4).Updates(map[string]any{"created_at": baseTime.Add(20 * time.Minute), "updated_at": baseTime.Add(20 * time.Minute)}).Error) + + otherAgent := &model.User{AccountID: s.account.ID, Name: "Other CSAT Agent", Email: "other-csat-agent@example.com", Role: "agent", Active: true} + s.Require().NoError(s.db.Create(otherAgent).Error) + s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: s.account.ID, UserID: otherAgent.ID, Role: "agent"}).Error) + otherContact := &model.Contact{AccountID: s.account.ID, Name: "Other CSAT Contact"} + s.Require().NoError(s.db.Create(otherContact).Error) + otherInbox := &model.Inbox{AccountID: s.account.ID, Name: "Other CSAT Inbox", ChannelType: "web_widget", Enabled: true} + s.Require().NoError(s.db.Create(otherInbox).Error) + otherTeamID := uint(99) + otherConversation := &model.Conversation{AccountID: s.account.ID, InboxID: otherInbox.ID, ContactID: otherContact.ID, AssigneeID: &otherAgent.ID, TeamID: &otherTeamID, Status: "resolved", ChannelType: "web_widget", Channel: "web_widget"} + s.Require().NoError(s.db.Create(otherConversation).Error) + otherMessage := &model.Message{ConversationID: otherConversation.ID, AccountID: s.account.ID, InboxID: otherInbox.ID, ContentType: "input_csat", MessageType: "outgoing", Content: "Rate other"} + s.Require().NoError(s.db.Create(otherMessage).Error) + s.Require().NoError(s.db.Model(otherMessage).Updates(map[string]any{"created_at": baseTime.Add(25 * time.Minute), "updated_at": baseTime.Add(25 * time.Minute)}).Error) + s.Require().NoError(s.db.Create(&automation.CsatSurveyResponse{AccountID: s.account.ID, ConversationID: otherConversation.ID, ContactID: otherContact.ID, AssignedAgentID: &otherAgent.ID, Rating: 1}).Error) + s.Require().NoError(s.db.Model(&automation.CsatSurveyResponse{}).Where("rating = ?", 1).Updates(map[string]any{"created_at": baseTime.Add(25 * time.Minute), "updated_at": baseTime.Add(25 * time.Minute)}).Error) + + outOfRange := &automation.CsatSurveyResponse{AccountID: s.account.ID, ConversationID: conversation.ID, ContactID: contact.ID, AssignedAgentID: &agent.ID, Rating: 3} + s.Require().NoError(s.db.Create(outOfRange).Error) + s.Require().NoError(s.db.Model(outOfRange).Updates(map[string]any{"created_at": baseTime.Add(3 * time.Hour), "updated_at": baseTime.Add(3 * time.Hour)}).Error) + + r := gin.New() + r.GET("/api/v1/accounts/:account_id/csat_survey_responses/metrics", s.handler.Metrics) + + url := fmt.Sprintf("/api/v1/accounts/%d/csat_survey_responses/metrics?since=%d&until=%d&user_ids[]=%d&inbox_id=%d&team_id=%d", + s.account.ID, baseTime.Unix(), baseTime.Add(time.Hour).Unix(), agent.ID, conversation.InboxID, *conversation.TeamID) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, url, nil) + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var payload map[string]any + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload)) + assert.Equal(s.T(), float64(2), payload["total_count"]) + assert.Equal(s.T(), float64(2), payload["total_sent_messages_count"]) + ratings := payload["ratings_count"].(map[string]any) + assert.Equal(s.T(), float64(1), ratings["5"]) + assert.Equal(s.T(), float64(1), ratings["4"]) + assert.NotContains(s.T(), ratings, "1") + assert.NotContains(s.T(), ratings, "3") + + ratingURL := url + "&rating=4" + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, ratingURL, nil) + r.ServeHTTP(w, req) + assert.Equal(s.T(), http.StatusOK, w.Code) + payload = nil + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload)) + assert.Equal(s.T(), float64(1), payload["total_count"]) + assert.Equal(s.T(), float64(2), payload["total_sent_messages_count"]) + ratings = payload["ratings_count"].(map[string]any) + assert.Equal(s.T(), float64(1), ratings["4"]) + assert.NotContains(s.T(), ratings, "5") +} + func (s *CsatSurveyHandlerTestSuite) TestDownload_ChatwootCSVAndFilters() { createdAt := time.Date(2026, 6, 5, 10, 30, 0, 0, time.UTC) agent, _, contact, conversation, _ := s.seedAccountCsatResponseGraph(createdAt, 5) @@ -324,6 +457,63 @@ func (s *CsatSurveyHandlerTestSuite) TestUpdate_Success() { assert.Equal(s.T(), reviewer.Name, reviewerPayload["name"]) } +func (s *CsatSurveyHandlerTestSuite) TestUpdate_ChatwootReviewNotesAuditAndSerializerParity() { + _, reviewer, _, conversation, _ := s.seedAccountCsatResponseGraph(time.Now().Add(-time.Hour), 5) + var survey automation.CsatSurveyResponse + s.Require().NoError(s.db.First(&survey).Error) + + r := gin.New() + r.PATCH("/api/v1/accounts/:account_id/csat_survey_responses/:id", func(c *gin.Context) { + c.Set("account_id", s.account.ID) + c.Set("user_id", reviewer.ID) + s.handler.Update(c) + }) + + w := httptest.NewRecorder() + body := `{"csat_review_notes":"dashboard review note","rating":1,"feedback_message":"must stay unchanged"}` + req, _ := http.NewRequest(http.MethodPatch, fmt.Sprintf("/api/v1/accounts/%d/csat_survey_responses/%d", s.account.ID, survey.ID), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Request-ID", "csat-review-note-audit") + req.RemoteAddr = "203.0.113.44:1234" + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var payload map[string]any + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload)) + assert.Equal(s.T(), "dashboard review note", payload["csat_review_notes"]) + assert.Equal(s.T(), float64(survey.Rating), payload["rating"]) + assert.Equal(s.T(), survey.FeedbackMessage, payload["feedback_message"]) + assert.Equal(s.T(), float64(*conversation.DisplayID), payload["conversation_id"]) + assert.NotNil(s.T(), payload["review_notes_updated_at"]) + reviewerPayload := payload["review_notes_updated_by"].(map[string]any) + assert.Equal(s.T(), reviewer.Name, reviewerPayload["name"]) + + var persisted automation.CsatSurveyResponse + s.Require().NoError(s.db.First(&persisted, survey.ID).Error) + assert.Equal(s.T(), "dashboard review note", persisted.CsatReviewNotes) + assert.Equal(s.T(), survey.Rating, persisted.Rating) + assert.Equal(s.T(), survey.FeedbackMessage, persisted.FeedbackMessage) + s.Require().NotNil(persisted.ReviewNotesUpdatedByID) + assert.Equal(s.T(), reviewer.ID, *persisted.ReviewNotesUpdatedByID) + s.Require().NotNil(persisted.ReviewNotesUpdatedAt) + + var audits []model.Audit + s.Require().NoError(s.db.Order("id ASC").Find(&audits).Error) + s.Require().Len(audits, 1) + audit := audits[0] + s.Require().NotNil(audit.AccountID) + assert.Equal(s.T(), s.account.ID, *audit.AccountID) + assert.Equal(s.T(), "CsatSurveyResponse", audit.AuditableType) + assert.Equal(s.T(), survey.ID, audit.AuditableID) + assert.Equal(s.T(), "update", audit.Action) + assert.Equal(s.T(), "csat-review-note-audit", audit.RequestUUID) + s.Require().NotNil(audit.UserID) + assert.Equal(s.T(), reviewer.ID, *audit.UserID) + var changes map[string]any + s.Require().NoError(json.Unmarshal(audit.AuditedChanges, &changes)) + assert.Equal(s.T(), "dashboard review note", changes["csat_review_notes"]) +} + func (s *CsatSurveyHandlerTestSuite) TestUpdate_NotFoundAcrossAccountScope() { survey := &automation.CsatSurveyResponse{AccountID: s.account.ID + 1, ConversationID: 1, ContactID: 1, Rating: 5} s.Require().NoError(s.db.Create(survey).Error) @@ -407,7 +597,7 @@ func (s *CsatSurveyHandlerTestSuite) TestPublicCsatShowAndUpdate_Success() { } func (s *CsatSurveyHandlerTestSuite) TestPublicCsatUpdate_LockedAfter14Days() { - conversation, _ := s.seedPublicCsatSurvey(time.Now().AddDate(0, 0, -15)) + conversation, message := s.seedPublicCsatSurvey(time.Now().AddDate(0, 0, -15)) r := gin.New() r.PATCH("/public/api/v1/csat_survey/:id", s.handler.PublicUpdate) @@ -419,6 +609,50 @@ func (s *CsatSurveyHandlerTestSuite) TestPublicCsatUpdate_LockedAfter14Days() { r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code) assert.Contains(s.T(), w.Body.String(), "You cannot update the CSAT survey after 14 days") + + var count int64 + s.Require().NoError(s.db.Model(&automation.CsatSurveyResponse{}).Where("message_id = ?", message.ID).Count(&count).Error) + assert.Equal(s.T(), int64(0), count) + var storedMessage model.Message + s.Require().NoError(s.db.First(&storedMessage, message.ID).Error) + assert.JSONEq(s.T(), `{}`, string(storedMessage.ContentAttributes)) +} + +func (s *CsatSurveyHandlerTestSuite) TestPublicCsatUpdate_ChatwootPutPersistsShowPayload() { + conversation, message := s.seedPublicCsatSurvey(time.Now().Add(-time.Hour)) + + r := gin.New() + r.PUT("/public/api/v1/csat_survey/:id", s.handler.PublicUpdate) + r.GET("/public/api/v1/csat_survey/:id", s.handler.PublicGet) + + var count int64 + s.Require().NoError(s.db.Model(&automation.CsatSurveyResponse{}).Where("message_id = ?", message.ID).Count(&count).Error) + assert.Equal(s.T(), int64(0), count) + + valid := httptest.NewRecorder() + validBody := `{"message":{"submitted_values":{"csat_survey_response":{"rating":5,"feedback_message":"great via put"}}}}` + validReq, _ := http.NewRequest(http.MethodPut, "/public/api/v1/csat_survey/"+conversation.UUID, bytes.NewBufferString(validBody)) + validReq.Header.Set("Content-Type", "application/json") + r.ServeHTTP(valid, validReq) + assert.Equal(s.T(), http.StatusOK, valid.Code) + + var updatePayload map[string]any + s.Require().NoError(json.Unmarshal(valid.Body.Bytes(), &updatePayload)) + assertChatwootPublicCSATShape(s.T(), updatePayload) + csatResp := updatePayload["csat_survey_response"].(map[string]any) + assert.Equal(s.T(), float64(5), csatResp["rating"]) + assert.Equal(s.T(), "great via put", csatResp["feedback_message"]) + + show := httptest.NewRecorder() + showReq, _ := http.NewRequest(http.MethodGet, "/public/api/v1/csat_survey/"+conversation.UUID, nil) + r.ServeHTTP(show, showReq) + assert.Equal(s.T(), http.StatusOK, show.Code) + var showPayload map[string]any + s.Require().NoError(json.Unmarshal(show.Body.Bytes(), &showPayload)) + assertChatwootPublicCSATShape(s.T(), showPayload) + showResp := showPayload["csat_survey_response"].(map[string]any) + assert.Equal(s.T(), float64(5), showResp["rating"]) + assert.Equal(s.T(), "great via put", showResp["feedback_message"]) } func (s *CsatSurveyHandlerTestSuite) TestList_BadRequest_InvalidAccountID() { @@ -518,3 +752,100 @@ func (s *CsatSurveyHandlerTestSuite) seedAccountCsatResponseGraph(createdAt time s.Require().NoError(s.db.Model(response).Updates(map[string]any{"created_at": createdAt, "updated_at": createdAt}).Error) return agent, reviewer, contact, conversation, message } + +func decodeCSATObject(t *testing.T, body string) map[string]any { + t.Helper() + payload := map[string]any{} + if err := json.Unmarshal([]byte(body), &payload); err != nil { + t.Fatalf("expected JSON object: %v\n%s", err, body) + } + return payload +} + +func decodeCSATArray(t *testing.T, body string) []map[string]any { + t.Helper() + payload := []map[string]any{} + if err := json.Unmarshal([]byte(body), &payload); err != nil { + t.Fatalf("expected JSON array: %v\n%s", err, body) + } + return payload +} + +func assertChatwootCSATListItemShape(t *testing.T, payload map[string]any) { + t.Helper() + for _, key := range []string{"id", "rating", "feedback_message", "csat_review_notes", "review_notes_updated_at", "account_id", "message_id", "conversation_id", "created_at", "contact", "assigned_agent", "review_notes_updated_by"} { + if _, ok := payload[key]; !ok { + t.Fatalf("expected CSAT list item to include %q, got %#v", key, payload) + } + } + assertNestedKeys(t, payload["contact"], []string{"id", "name", "email"}) + assertNestedKeys(t, payload["assigned_agent"], []string{"id", "name", "email"}) + assertNestedKeys(t, payload["review_notes_updated_by"], []string{"id", "name"}) +} + +func assertChatwootCSATMetricsShape(t *testing.T, payload map[string]any) { + t.Helper() + for _, key := range []string{"total_count", "ratings_count", "total_sent_messages_count"} { + if _, ok := payload[key]; !ok { + t.Fatalf("expected CSAT metrics to include %q, got %#v", key, payload) + } + } +} + +func assertChatwootCSATCSVShape(t *testing.T, recorder *httptest.ResponseRecorder, expectedHeaders []string) { + t.Helper() + if contentType := recorder.Header().Get("Content-Type"); !strings.Contains(contentType, "text/csv") { + t.Fatalf("expected CSV content type, got %q", contentType) + } + if disposition := recorder.Header().Get("Content-Disposition"); !strings.Contains(disposition, "csat_report.csv") { + t.Fatalf("expected CSAT CSV attachment, got %q", disposition) + } + rows := readCSATCSVRows(t, recorder.Body.String()) + if len(rows) < 2 { + t.Fatalf("expected CSAT CSV header and data rows, got %#v", rows) + } + if len(rows[0]) != len(expectedHeaders) { + t.Fatalf("expected CSAT CSV headers %#v, got %#v", expectedHeaders, rows[0]) + } + for idx, expected := range expectedHeaders { + if rows[0][idx] != expected { + t.Fatalf("expected CSAT CSV headers %#v, got %#v", expectedHeaders, rows[0]) + } + } +} + +func readCSATCSVRows(t *testing.T, body string) [][]string { + t.Helper() + reader := csv.NewReader(strings.NewReader(body)) + reader.FieldsPerRecord = -1 + rows, err := reader.ReadAll() + if err != nil { + t.Fatalf("failed to read CSAT CSV: %v\n%s", err, body) + } + return rows +} + +func assertChatwootPublicCSATShape(t *testing.T, payload map[string]any) { + t.Helper() + for _, key := range []string{"id", "content", "display_type", "inbox_name", "inbox_avatar_url", "locale", "conversation_id", "created_at", "csat_survey_response"} { + if _, ok := payload[key]; !ok { + t.Fatalf("expected public CSAT payload to include %q, got %#v", key, payload) + } + } + if payload["csat_survey_response"] != nil { + assertNestedKeys(t, payload["csat_survey_response"], []string{"rating", "feedback_message"}) + } +} + +func assertNestedKeys(t *testing.T, payload any, keys []string) { + t.Helper() + object, ok := payload.(map[string]any) + if !ok { + t.Fatalf("expected nested object, got %#v", payload) + } + for _, key := range keys { + if _, ok := object[key]; !ok { + t.Fatalf("expected nested object to include %q, got %#v", key, object) + } + } +} diff --git a/internal/handler/api/v1/custom_attribute_definition_handler_test.go b/internal/handler/api/v1/custom_attribute_definition_handler_test.go index 7e35a7a6..a3a4a35b 100644 --- a/internal/handler/api/v1/custom_attribute_definition_handler_test.go +++ b/internal/handler/api/v1/custom_attribute_definition_handler_test.go @@ -117,6 +117,34 @@ func TestCustomAttributeDefinitionHandler_List(t *testing.T) { assert.Len(t, resp, 2) } +func TestCustomAttributeDefinitionHandler_ListChatwootFrontendPayload(t *testing.T) { + r, handler, db, accountID := setupCustomAttrDefHandlerTest(t) + + seedCustomAttrDef(t, db, accountID, "plan", "Plan", "list", "contact") + r.GET("/api/v1/accounts/:account_id/custom_attribute_definitions", handler.List) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/custom_attribute_definitions?attribute_model=contact_attribute", accountID), nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp []map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Len(t, resp, 1) + item := resp[0] + assert.Equal(t, "plan", item["attribute_key"]) + assert.Equal(t, "Plan", item["attribute_display_name"]) + assert.Equal(t, "list", item["attribute_display_type"]) + assert.Equal(t, "contact_attribute", item["attribute_model"]) + assert.Contains(t, item, "attribute_description") + assert.Contains(t, item, "attribute_values") + assert.Contains(t, item, "default_value") + assert.Contains(t, item, "regex_pattern") + assert.Contains(t, item, "regex_cue") + assert.Contains(t, item, "created_at") + assert.Contains(t, item, "updated_at") +} + func TestCustomAttributeDefinitionHandler_List_FilterByModel(t *testing.T) { r, handler, db, accountID := setupCustomAttrDefHandlerTest(t) diff --git a/internal/handler/api/v1/custom_role_handler.go b/internal/handler/api/v1/custom_role_handler.go index 04292468..951ae700 100644 --- a/internal/handler/api/v1/custom_role_handler.go +++ b/internal/handler/api/v1/custom_role_handler.go @@ -203,7 +203,9 @@ func (h *CustomRoleHandler) Delete(c *gin.Context) { func RegisterCustomRoleRoutes(rg *gin.RouterGroup, h *CustomRoleHandler) { customRoles := rg.Group("/custom_roles") { + customRoles.GET("", h.List) customRoles.GET("/", h.List) + customRoles.POST("", h.Create) customRoles.POST("/", h.Create) customRoles.GET("/:id", h.Get) customRoles.PATCH("/:id", h.Update) diff --git a/internal/handler/api/v1/custom_role_handler_test.go b/internal/handler/api/v1/custom_role_handler_test.go index fca5870e..d4131f0d 100644 --- a/internal/handler/api/v1/custom_role_handler_test.go +++ b/internal/handler/api/v1/custom_role_handler_test.go @@ -148,6 +148,68 @@ func (s *CustomRoleHandlerTestSuite) TestCreate_Success() { s.NotContains(payload, "data") } +func (s *CustomRoleHandlerTestSuite) TestChatwootPermissionSetCreateUpdateListShowParity() { + chatwootPermissions := []string{ + "conversation_manage", + "conversation_unassigned_manage", + "conversation_participating_manage", + "contact_manage", + "report_manage", + "knowledge_base_manage", + } + + r := gin.New() + r.POST("/api/v1/accounts/:account_id/custom_roles", withCustomRoleAdminContext(s.account.ID, s.handler.Create)) + r.GET("/api/v1/accounts/:account_id/custom_roles", withCustomRoleAdminContext(s.account.ID, s.handler.List)) + r.GET("/api/v1/accounts/:account_id/custom_roles/:id", withCustomRoleAdminContext(s.account.ID, s.handler.Get)) + r.PUT("/api/v1/accounts/:account_id/custom_roles/:id", withCustomRoleAdminContext(s.account.ID, s.handler.Update)) + + createBody := fmt.Sprintf(`{"custom_role":{"name":"chatwoot-full-permissions","description":"all enterprise permissions","permissions":%s}}`, mustJSONForCustomRoleTest(s.T(), chatwootPermissions)) + createW := httptest.NewRecorder() + createReq, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/custom_roles", s.account.ID), bytes.NewBufferString(createBody)) + createReq.Header.Set("Content-Type", "application/json") + r.ServeHTTP(createW, createReq) + s.Require().Equal(http.StatusOK, createW.Code) + + var created map[string]any + s.Require().NoError(json.Unmarshal(createW.Body.Bytes(), &created)) + s.Equal("chatwoot-full-permissions", created["name"]) + s.Equal("all enterprise permissions", created["description"]) + s.Equal(toInterfaceStrings(chatwootPermissions), created["permissions"]) + s.NotContains(created, "data") + s.NotContains(created, "success") + roleID := uint(created["id"].(float64)) + + showW := httptest.NewRecorder() + showReq, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/custom_roles/%d", s.account.ID, roleID), nil) + r.ServeHTTP(showW, showReq) + s.Require().Equal(http.StatusOK, showW.Code) + var shown map[string]any + s.Require().NoError(json.Unmarshal(showW.Body.Bytes(), &shown)) + s.Equal(toInterfaceStrings(chatwootPermissions), shown["permissions"]) + + listW := httptest.NewRecorder() + listReq, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/custom_roles", s.account.ID), nil) + r.ServeHTTP(listW, listReq) + s.Require().Equal(http.StatusOK, listW.Code) + var listed []map[string]any + s.Require().NoError(json.Unmarshal(listW.Body.Bytes(), &listed)) + s.Require().Len(listed, 1) + s.Equal(toInterfaceStrings(chatwootPermissions), listed[0]["permissions"]) + + updateBody := `{"custom_role":{"name":"chatwoot-cleared-permissions","description":"","permissions":[]}}` + updateW := httptest.NewRecorder() + updateReq, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/accounts/%d/custom_roles/%d", s.account.ID, roleID), bytes.NewBufferString(updateBody)) + updateReq.Header.Set("Content-Type", "application/json") + r.ServeHTTP(updateW, updateReq) + s.Require().Equal(http.StatusOK, updateW.Code) + var updated map[string]any + s.Require().NoError(json.Unmarshal(updateW.Body.Bytes(), &updated)) + s.Equal("chatwoot-cleared-permissions", updated["name"]) + s.Equal("", updated["description"]) + s.Empty(updated["permissions"]) +} + func (s *CustomRoleHandlerTestSuite) TestGet_Success() { // Create a custom role first role := &model.CustomRole{AccountID: s.account.ID, Name: "get-test-role", Permissions: `["report_manage"]`} @@ -314,3 +376,20 @@ func withCustomRoleAdminContext(accountID uint, h gin.HandlerFunc) gin.HandlerFu h(c) } } + +func mustJSONForCustomRoleTest(t *testing.T, value any) string { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatalf("failed to marshal custom role test value: %v", err) + } + return string(data) +} + +func toInterfaceStrings(values []string) []any { + out := make([]any, 0, len(values)) + for _, value := range values { + out = append(out, value) + } + return out +} diff --git a/internal/handler/api/v1/draft_message_handler_test.go b/internal/handler/api/v1/draft_message_handler_test.go index a37f65e3..83fcfe10 100644 --- a/internal/handler/api/v1/draft_message_handler_test.go +++ b/internal/handler/api/v1/draft_message_handler_test.go @@ -217,6 +217,40 @@ func (s *DraftMessageHandlerTestSuite) Test_ShowDraft_UsesDisplayIDRoute() { assert.Equal(s.T(), "Display draft", resp["message"]) } +func (s *DraftMessageHandlerTestSuite) Test_CollectionDrafts_UseDisplayIDRoute() { + s.Require().NoError(s.db.Exec("DELETE FROM draft_messages").Error) + displayID := uint(92) + s.testConv.DisplayID = &displayID + s.Require().NoError(s.db.Save(s.testConv).Error) + + body := map[string]interface{}{"content": "Display collection draft"} + b, _ := json.Marshal(body) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", makeDraftCollectionURL(s.testAccount.ID, displayID), bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var created map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &created)) + data := created["data"].(map[string]interface{}) + assert.Equal(s.T(), "Display collection draft", data["content"]) + assert.Equal(s.T(), float64(s.testConv.ID), data["conversation_id"]) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", makeDraftCollectionURL(s.testAccount.ID, displayID), nil) + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var listed map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &listed)) + items := listed["data"].([]interface{}) + s.Require().Len(items, 1) + item := items[0].(map[string]interface{}) + assert.Equal(s.T(), "Display collection draft", item["content"]) + assert.Equal(s.T(), float64(s.testConv.ID), item["conversation_id"]) +} + func (s *DraftMessageHandlerTestSuite) Test_ListDrafts() { // First create a draft body := map[string]interface{}{ diff --git a/internal/handler/api/v1/enterprise_account_handler.go b/internal/handler/api/v1/enterprise_account_handler.go index 072b87cc..e5908c9b 100644 --- a/internal/handler/api/v1/enterprise_account_handler.go +++ b/internal/handler/api/v1/enterprise_account_handler.go @@ -18,10 +18,17 @@ func NewEnterpriseAccountHandler(svc *service.AccountService) *EnterpriseAccount return &EnterpriseAccountHandler{svc: svc} } +func enterpriseAccountID(c *gin.Context) uint { + if id := parseAccountIDParam(c); id != 0 { + return id + } + return getAccountID(c) +} + // Limits returns account usage limits in Chatwoot's enterprise payload shape. // GET /enterprise/api/v1/accounts/:account_id/limits func (h *EnterpriseAccountHandler) Limits(c *gin.Context) { - accountID := parseAccountIDParam(c) + accountID := enterpriseAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return @@ -38,7 +45,7 @@ func (h *EnterpriseAccountHandler) Limits(c *gin.Context) { // ToggleDeletion marks or unmarks an account for scheduled deletion. // POST /enterprise/api/v1/accounts/:account_id/toggle_deletion func (h *EnterpriseAccountHandler) ToggleDeletion(c *gin.Context) { - accountID := parseAccountIDParam(c) + accountID := enterpriseAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return @@ -70,7 +77,7 @@ func (h *EnterpriseAccountHandler) ToggleDeletion(c *gin.Context) { // Subscription mirrors the Cloud customer-creation guard and returns no content. // POST /enterprise/api/v1/accounts/:account_id/subscription func (h *EnterpriseAccountHandler) Subscription(c *gin.Context) { - accountID := parseAccountIDParam(c) + accountID := enterpriseAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return @@ -85,7 +92,7 @@ func (h *EnterpriseAccountHandler) Subscription(c *gin.Context) { // Checkout returns Chatwoot's billing-details error when no Stripe session can be created locally. // POST /enterprise/api/v1/accounts/:account_id/checkout func (h *EnterpriseAccountHandler) Checkout(c *gin.Context) { - accountID := parseAccountIDParam(c) + accountID := enterpriseAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return @@ -100,7 +107,7 @@ func (h *EnterpriseAccountHandler) Checkout(c *gin.Context) { // TopupCheckout validates credits and exposes a provider-unavailable boundary for local installs. // POST /enterprise/api/v1/accounts/:account_id/topup_checkout func (h *EnterpriseAccountHandler) TopupCheckout(c *gin.Context) { - accountID := parseAccountIDParam(c) + accountID := enterpriseAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return diff --git a/internal/handler/api/v1/enterprise_account_handler_test.go b/internal/handler/api/v1/enterprise_account_handler_test.go index 38e6ba6f..0b634ad8 100644 --- a/internal/handler/api/v1/enterprise_account_handler_test.go +++ b/internal/handler/api/v1/enterprise_account_handler_test.go @@ -58,6 +58,36 @@ func TestEnterpriseAccountLimits_ChatwootPayload(t *testing.T) { require.Equal(t, float64(2), responses["consumed"]) } +func TestEnterpriseAccountLimits_CaptainUsageDoesNotExposeNegativeAvailability(t *testing.T) { + router, db, account, user := setupEnterpriseAccountHandlerTest(t) + + account.Limits = datatypes.JSON(`{"captain_documents":1,"captain_responses":2}`) + require.NoError(t, account.SetCustomAttributesMap(map[string]any{ + "captain_documents_usage": 3, + "captain_responses_usage": 4, + })) + require.NoError(t, db.Save(account).Error) + require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}).Error) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/enterprise/api/v1/accounts/%d/limits", account.ID), 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)) + limits := body["limits"].(map[string]any) + captain := limits["captain"].(map[string]any) + documents := captain["documents"].(map[string]any) + responses := captain["responses"].(map[string]any) + require.Equal(t, float64(1), documents["total_count"]) + require.Equal(t, float64(3), documents["consumed"]) + require.Equal(t, float64(0), documents["current_available"]) + require.Equal(t, float64(2), responses["total_count"]) + require.Equal(t, float64(4), responses["consumed"]) + require.Equal(t, float64(0), responses["current_available"]) +} + func TestEnterpriseAccountLimits_DefaultPlanPayload(t *testing.T) { router, db, account, user := setupEnterpriseAccountHandlerTest(t) require.NoError(t, account.SetCustomAttributesMap(map[string]any{"default_plan": true})) @@ -87,6 +117,51 @@ func TestEnterpriseAccountLimits_DefaultPlanPayload(t *testing.T) { require.Equal(t, float64(1), nonWeb["consumed"]) } +func TestEnterpriseAccountLiteralFrontendPathsUseCurrentAccountContext(t *testing.T) { + router, db, account, user := setupEnterpriseAccountHandlerTest(t) + account.AgentLimit = 4 + account.Limits = datatypes.JSON(`{"captain_documents":3,"captain_responses":9}`) + require.NoError(t, db.Save(account).Error) + require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}).Error) + require.NoError(t, db.Create(&model.CaptainDocument{AccountID: account.ID, AssistantID: 1, Name: "Doc", ExternalLink: "https://example.com"}).Error) + + limits := httptest.NewRecorder() + limitsReq := httptest.NewRequest(http.MethodGet, "/enterprise/api/v1/limits", nil) + limitsReq.Header.Set("X-Account-ID", fmt.Sprint(account.ID)) + router.ServeHTTP(limits, limitsReq) + require.Equal(t, http.StatusOK, limits.Code, limits.Body.String()) + var body map[string]any + require.NoError(t, json.Unmarshal(limits.Body.Bytes(), &body)) + require.Equal(t, float64(account.ID), body["id"]) + limitPayload := body["limits"].(map[string]any) + agents := limitPayload["agents"].(map[string]any) + require.Equal(t, float64(4), agents["allowed"]) + require.Equal(t, float64(1), agents["consumed"]) + captain := limitPayload["captain"].(map[string]any) + documents := captain["documents"].(map[string]any) + require.Equal(t, float64(3), documents["total_count"]) + require.Equal(t, float64(1), documents["consumed"]) + require.Equal(t, float64(2), documents["current_available"]) + + subscription := enterpriseAccountLiteralRequest(t, router, account.ID, http.MethodPost, "subscription", ``) + require.Equal(t, http.StatusNoContent, subscription.Code, subscription.Body.String()) + require.NoError(t, db.First(account, account.ID).Error) + require.Equal(t, true, account.CustomAttributesMap()["is_creating_customer"]) + + checkout := enterpriseAccountLiteralRequest(t, router, account.ID, http.MethodPost, "checkout", ``) + require.Equal(t, http.StatusUnprocessableEntity, checkout.Code, checkout.Body.String()) + require.Contains(t, checkout.Body.String(), "Please subscribe to a plan before viewing the billing details") + + topupMissingCredits := enterpriseAccountLiteralRequest(t, router, account.ID, http.MethodPost, "topup_checkout", `{}`) + require.Equal(t, http.StatusUnprocessableEntity, topupMissingCredits.Code, topupMissingCredits.Body.String()) + require.Contains(t, topupMissingCredits.Body.String(), "Credits are required") + + deleteResp := enterpriseAccountLiteralRequest(t, router, account.ID, http.MethodPost, "toggle_deletion", `{"action_type":"delete"}`) + require.Equal(t, http.StatusOK, deleteResp.Code, deleteResp.Body.String()) + require.NoError(t, db.First(account, account.ID).Error) + require.Equal(t, "manual_deletion", account.CustomAttributesMap()["marked_for_deletion_reason"]) +} + func TestEnterpriseAccountToggleDeletion(t *testing.T) { router, db, account, user := setupEnterpriseAccountHandlerTest(t) require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}).Error) @@ -106,6 +181,44 @@ func TestEnterpriseAccountToggleDeletion(t *testing.T) { require.NotContains(t, attrs, "marked_for_deletion_at") } +func TestEnterpriseAccountBillingErrorBoundariesDoNotMutateAccount(t *testing.T) { + router, db, account, user := setupEnterpriseAccountHandlerTest(t) + require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}).Error) + require.NoError(t, account.SetCustomAttributesMap(map[string]any{"existing": "kept"})) + require.NoError(t, db.Save(account).Error) + + checkout := enterpriseAccountRequest(t, router, account.ID, http.MethodPost, "checkout", ``) + require.Equal(t, http.StatusUnprocessableEntity, checkout.Code, checkout.Body.String()) + var checkoutBody map[string]any + require.NoError(t, json.Unmarshal(checkout.Body.Bytes(), &checkoutBody)) + require.Equal(t, "Please subscribe to a plan before viewing the billing details", checkoutBody["error"]) + + missingCredits := enterpriseAccountRequest(t, router, account.ID, http.MethodPost, "topup_checkout", `{}`) + require.Equal(t, http.StatusUnprocessableEntity, missingCredits.Code, missingCredits.Body.String()) + var missingCreditsBody map[string]any + require.NoError(t, json.Unmarshal(missingCredits.Body.Bytes(), &missingCreditsBody)) + require.Equal(t, "Credits are required", missingCreditsBody["error"]) + + providerUnavailable := enterpriseAccountRequest(t, router, account.ID, http.MethodPost, "topup_checkout", `{"credits":50}`) + require.Equal(t, http.StatusUnprocessableEntity, providerUnavailable.Code, providerUnavailable.Body.String()) + var providerUnavailableBody map[string]any + require.NoError(t, json.Unmarshal(providerUnavailable.Body.Bytes(), &providerUnavailableBody)) + require.Equal(t, "Top-up checkout provider is not configured", providerUnavailableBody["error"]) + + invalidToggle := enterpriseAccountRequest(t, router, account.ID, http.MethodPost, "toggle_deletion", `{"action_type":"archive"}`) + require.Equal(t, http.StatusUnprocessableEntity, invalidToggle.Code, invalidToggle.Body.String()) + var invalidToggleBody map[string]any + require.NoError(t, json.Unmarshal(invalidToggle.Body.Bytes(), &invalidToggleBody)) + require.Equal(t, `Invalid action_type. Must be either "delete" or "undelete"`, invalidToggleBody["error"]) + + require.NoError(t, db.First(account, account.ID).Error) + attrs := account.CustomAttributesMap() + require.Equal(t, "kept", attrs["existing"]) + require.NotContains(t, attrs, "is_creating_customer") + require.NotContains(t, attrs, "marked_for_deletion_reason") + require.NotContains(t, attrs, "marked_for_deletion_at") +} + func TestEnterpriseAccountSubscriptionSetsCreationFlag(t *testing.T) { router, db, account, user := setupEnterpriseAccountHandlerTest(t) require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}).Error) @@ -116,6 +229,24 @@ func TestEnterpriseAccountSubscriptionSetsCreationFlag(t *testing.T) { require.Equal(t, true, account.CustomAttributesMap()["is_creating_customer"]) } +func TestEnterpriseAccountSubscriptionPreservesExistingCustomerState(t *testing.T) { + router, db, account, user := setupEnterpriseAccountHandlerTest(t) + require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}).Error) + require.NoError(t, account.SetCustomAttributesMap(map[string]any{ + "stripe_customer_id": "cus_existing", + "existing": "kept", + })) + require.NoError(t, db.Save(account).Error) + + w := enterpriseAccountRequest(t, router, account.ID, http.MethodPost, "subscription", ``) + require.Equal(t, http.StatusNoContent, w.Code, w.Body.String()) + require.NoError(t, db.First(account, account.ID).Error) + attrs := account.CustomAttributesMap() + require.Equal(t, "cus_existing", attrs["stripe_customer_id"]) + require.Equal(t, "kept", attrs["existing"]) + require.NotContains(t, attrs, "is_creating_customer") +} + func TestEnterpriseAccountRejectsAccountOutsideCurrentUser(t *testing.T) { router, _, account, _ := setupEnterpriseAccountHandlerTest(t) @@ -154,6 +285,11 @@ func setupEnterpriseAccountHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB, *mo accounts.POST("/:account_id/subscription", handler.Subscription) accounts.POST("/:account_id/checkout", handler.Checkout) accounts.POST("/:account_id/topup_checkout", handler.TopupCheckout) + router.GET("/enterprise/api/v1/limits", handler.Limits) + router.POST("/enterprise/api/v1/subscription", handler.Subscription) + router.POST("/enterprise/api/v1/checkout", handler.Checkout) + router.POST("/enterprise/api/v1/topup_checkout", handler.TopupCheckout) + router.POST("/enterprise/api/v1/toggle_deletion", handler.ToggleDeletion) return router, db, account, user } @@ -172,3 +308,13 @@ func enterpriseAccountRequest(t *testing.T, router *gin.Engine, accountID uint, router.ServeHTTP(w, req) return w } + +func enterpriseAccountLiteralRequest(t *testing.T, router *gin.Engine, accountID uint, method, action, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, fmt.Sprintf("/enterprise/api/v1/%s", action), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Account-ID", fmt.Sprint(accountID)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w +} diff --git a/internal/handler/api/v1/facebook_callbacks_handler_test.go b/internal/handler/api/v1/facebook_callbacks_handler_test.go index 175eac81..9b295a2c 100644 --- a/internal/handler/api/v1/facebook_callbacks_handler_test.go +++ b/internal/handler/api/v1/facebook_callbacks_handler_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -23,20 +24,32 @@ import ( ) type fakeFacebookCallbackProvider struct { - longToken string - pages []facebookchannel.FBPageInfo - instagramID string + longToken string + pages []facebookchannel.FBPageInfo + instagramID string + tokenErr error + pagesErr error + instagramErr error } func (f *fakeFacebookCallbackProvider) ExchangeLongLivedUserToken(context.Context, string) (string, error) { + if f.tokenErr != nil { + return "", f.tokenErr + } return f.longToken, nil } func (f *fakeFacebookCallbackProvider) ListFacebookPages(context.Context, string) ([]facebookchannel.FBPageInfo, error) { + if f.pagesErr != nil { + return nil, f.pagesErr + } return f.pages, nil } func (f *fakeFacebookCallbackProvider) FetchInstagramBusinessAccountID(context.Context, string) (string, error) { + if f.instagramErr != nil { + return "", f.instagramErr + } return f.instagramID, nil } @@ -76,6 +89,9 @@ func TestFacebookCallbacks_RegisterFacebookPage(t *testing.T) { assert.Equal(t, "Facebook", payload["name"]) assert.Equal(t, "facebook", payload["channel_type"]) assert.Equal(t, "page-1", payload["page_id"]) + assert.Equal(t, false, payload["enable_auto_assignment"]) + assert.NotContains(t, payload, "success") + assert.NotContains(t, payload, "data") var channel channelmodel.ChannelFacebook require.NoError(t, db.First(&channel).Error) @@ -83,6 +99,17 @@ func TestFacebookCallbacks_RegisterFacebookPage(t *testing.T) { assert.Equal(t, "user-token", channel.UserAccessToken) assert.Equal(t, "page-token", channel.PageAccessToken) assert.Equal(t, "ig-123", channel.InstagramBusinessAccountID) + + var inbox model.Inbox + require.NoError(t, db.First(&inbox, uint(payload["id"].(float64))).Error) + assert.Equal(t, account.ID, inbox.AccountID) + assert.Equal(t, "Facebook", inbox.Name) + assert.Equal(t, "facebook", inbox.ChannelType) + assert.Equal(t, channel.ID, inbox.ChannelID) + var config map[string]any + require.NoError(t, json.Unmarshal([]byte(inbox.ChannelConfig), &config)) + assert.Equal(t, "page-1", config["page_id"]) + assert.Equal(t, "page-token", config["page_access_token"]) } func TestFacebookCallbacks_FacebookPagesMarksExistingPages(t *testing.T) { @@ -111,6 +138,20 @@ func TestFacebookCallbacks_FacebookPagesMarksExistingPages(t *testing.T) { assert.False(t, payload.Data.PageDetails[1].Exists) } +func TestFacebookCallbacks_FacebookPagesReturns422ProviderFailure(t *testing.T) { + provider := &fakeFacebookCallbackProvider{tokenErr: errors.New("facebook token exchange failed")} + router, db := setupFacebookCallbackTest(t, provider) + account := model.Account{Name: "Acme", Active: true} + require.NoError(t, db.Create(&account).Error) + + rec := performFacebookCallbackRequest(t, router, http.MethodPost, "/api/v1/accounts/1/callbacks/facebook_pages.json", map[string]any{"omniauth_token": "bad-token"}) + require.Equal(t, http.StatusUnprocessableEntity, rec.Code) + + var payload map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) + assert.Equal(t, "facebook token exchange failed", payload["error"]) +} + func TestFacebookCallbacks_ReauthorizePageUpdatesMatchingPage(t *testing.T) { provider := &fakeFacebookCallbackProvider{longToken: "long-user-token", instagramID: "ig-456", pages: []facebookchannel.FBPageInfo{{ID: "page-1", Name: "Renamed", AccessToken: "new-page-token"}}} router, db := setupFacebookCallbackTest(t, provider) @@ -138,6 +179,7 @@ func TestFacebookCallbacks_ReauthorizePageUpdatesMatchingPage(t *testing.T) { var config map[string]any require.NoError(t, json.Unmarshal([]byte(updatedInbox.ChannelConfig), &config)) assert.Equal(t, "new-page-token", config["page_access_token"]) + assert.Equal(t, "Renamed", config["page_name"]) } func TestFacebookCallbacks_ReauthorizePageReturns422WhenPageMissing(t *testing.T) { diff --git a/internal/handler/api/v1/inbox_agentbot_avatar_campaigns_test.go b/internal/handler/api/v1/inbox_agentbot_avatar_campaigns_test.go index 87c0f04b..076d5791 100644 --- a/internal/handler/api/v1/inbox_agentbot_avatar_campaigns_test.go +++ b/internal/handler/api/v1/inbox_agentbot_avatar_campaigns_test.go @@ -16,6 +16,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/logger" + "github.com/gochat/gochat/internal/campaign" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" @@ -43,14 +44,14 @@ func setupInboxAgentBotDB(t *testing.T) (*gin.Engine, *gorm.DB, *model.Account, sqlDB.Close() } }) - require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.AgentBot{}, &model.AgentBotInbox{})) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.AgentBot{}, &model.AgentBotInbox{}, &campaign.Campaign{})) account := &model.Account{Name: "Agent bot org", Locale: "en", Active: true} require.NoError(t, db.Create(account).Error) inbox := &model.Inbox{AccountID: account.ID, Name: "Support", ChannelType: "web_widget", ChannelID: 1} require.NoError(t, db.Create(inbox).Error) bot := &model.AgentBot{AccountID: &account.ID, Name: "Triage bot", Description: "Routes chats", AvatarURL: "https://example.test/bot.png", OutgoingURL: "https://example.test/hook", BotType: "webhook", Config: json.RawMessage(`{"handoff":true}`), AccessToken: "access-token", Secret: "secret"} require.NoError(t, db.Create(bot).Error) - svc := service.NewInboxService(repository.NewInboxRepo(db), repository.NewAgentBotInboxRepo(db), repository.NewAgentBotRepo(db), nil, nil, nil, nil) + svc := service.NewInboxService(repository.NewInboxRepo(db), repository.NewAgentBotInboxRepo(db), repository.NewAgentBotRepo(db), repository.NewCampaignRepo(db), nil, nil, nil) return setupInboxAgentBotRouter(NewInboxHandler(svc)), db, account, inbox, bot } @@ -179,6 +180,21 @@ func TestInboxHandler_DeleteAvatar_ValidIDs_ZeroService(t *testing.T) { assert.True(t, w.Code == http.StatusUnprocessableEntity || w.Code == http.StatusOK) } +func TestInboxHandler_DeleteAvatar_ChatwootHeadOK(t *testing.T) { + router, db, account, inbox, _ := setupInboxAgentBotDB(t) + require.NoError(t, db.Model(inbox).Update("avatar_url", "https://example.test/inbox.png").Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodDelete, "/api/v1/accounts/"+inboxAgentBotTestID(account.ID)+"/inboxes/"+inboxAgentBotTestID(inbox.ID)+"/avatar", nil) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.Empty(t, w.Body.String()) + var reloaded model.Inbox + require.NoError(t, db.First(&reloaded, inbox.ID).Error) + assert.Empty(t, reloaded.AvatarURL) +} + // --- ListCampaigns tests --- func TestInboxHandler_ListCampaigns_InvalidAccountID(t *testing.T) { @@ -214,3 +230,22 @@ func TestInboxHandler_ListCampaigns_ValidIDs_ZeroService(t *testing.T) { // Zero service will panic on method call; Recovery middleware catches it → 500 assert.True(t, w.Code == http.StatusUnprocessableEntity || w.Code == http.StatusOK) } + +func TestInboxHandler_ListCampaigns_ChatwootPayload(t *testing.T) { + router, db, account, inbox, _ := setupInboxAgentBotDB(t) + require.NoError(t, db.Create(&campaign.Campaign{AccountID: account.ID, InboxID: inbox.ID, DisplayID: 42, Title: "Welcome", Message: "Hello", CampaignType: campaign.CampaignTypeOngoing, CampaignStatus: campaign.CampaignStatusActive, Enabled: true}).Error) + otherInbox := &model.Inbox{AccountID: account.ID, Name: "Other", ChannelType: "web_widget", ChannelID: 2} + require.NoError(t, db.Create(otherInbox).Error) + require.NoError(t, db.Create(&campaign.Campaign{AccountID: account.ID, InboxID: otherInbox.ID, DisplayID: 43, Title: "Other", Message: "Nope", CampaignType: campaign.CampaignTypeOngoing, CampaignStatus: campaign.CampaignStatusActive}).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/accounts/"+inboxAgentBotTestID(account.ID)+"/inboxes/"+inboxAgentBotTestID(inbox.ID)+"/campaigns", nil) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var resp map[string][]map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Len(t, resp["campaigns"], 1) + assert.Equal(t, "Welcome", resp["campaigns"][0]["title"]) + assert.Equal(t, float64(inbox.ID), resp["campaigns"][0]["inbox_id"]) +} diff --git a/internal/handler/api/v1/inbox_handler.go b/internal/handler/api/v1/inbox_handler.go index ae1c5fb6..6081e872 100644 --- a/internal/handler/api/v1/inbox_handler.go +++ b/internal/handler/api/v1/inbox_handler.go @@ -49,11 +49,15 @@ func (h *InboxHandler) WithAuditService(auditSvc *service.AuditService) *InboxHa // List retrieves all inboxes for an account. // GET /api/v1/accounts/:id/inboxes func (h *InboxHandler) List(c *gin.Context) { - accountID, err := parseUintParam(c, "id") - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) return } + if !h.svc.Ready() { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to list inboxes") + return + } page := getPage(c) perPage := getPageSize(c) @@ -85,11 +89,15 @@ func (h *InboxHandler) List(c *gin.Context) { // Get retrieves a single inbox. // GET /api/v1/accounts/:id/inboxes/:inbox_id func (h *InboxHandler) Get(c *gin.Context) { - accountID, err := parseUintParam(c, "id") - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) return } + if !h.svc.Ready() { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to get inbox") + return + } inboxID, err := parseUintParam(c, "inbox_id") if err != nil { @@ -122,8 +130,8 @@ func (h *InboxHandler) Get(c *gin.Context) { // Create creates a new inbox. // POST /api/v1/accounts/:id/inboxes func (h *InboxHandler) Create(c *gin.Context) { - accountID, err := parseUintParam(c, "id") - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) return } @@ -133,6 +141,10 @@ func (h *InboxHandler) Create(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()}) return } + if !h.svc.Ready() { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to create inbox") + return + } inbox, svcErr := h.svc.Create(c.Request.Context(), accountID, req) if svcErr != nil { @@ -210,8 +222,8 @@ func (h *InboxHandler) WhatsAppAuthorization(c *gin.Context) { // PUT /api/v1/accounts/:id/inboxes/:inbox_id // Reference: Chatwoot inboxes#update func (h *InboxHandler) Update(c *gin.Context) { - accountID, err := parseUintParam(c, "id") - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) return } @@ -227,6 +239,10 @@ func (h *InboxHandler) Update(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()}) return } + if !h.svc.Ready() { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to update inbox") + return + } inbox, svcErr := h.svc.Update(c.Request.Context(), accountID, inboxID, req) if svcErr != nil { @@ -262,11 +278,15 @@ func (h *InboxHandler) Update(c *gin.Context) { // DELETE /api/v1/accounts/:id/inboxes/:inbox_id // Reference: Chatwoot inboxes#destroy func (h *InboxHandler) Delete(c *gin.Context) { - accountID, err := parseUintParam(c, "id") - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) return } + if !h.svc.Ready() { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to delete inbox") + return + } inboxID, err := parseUintParam(c, "inbox_id") if err != nil { @@ -919,11 +939,7 @@ func (h *InboxHandler) ListCampaigns(c *gin.Context) { // POST /api/v1/accounts/:id/inboxes/:inbox_id/reset_secret // Reference: Chatwoot inboxes_controller#reset_secret — only works for API inboxes func (h *InboxHandler) ResetSecret(c *gin.Context) { - accountID, err := parseUintParam(c, "id") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) - return - } + accountID := parseAccountIDParam(c) inboxID, err := parseUintParam(c, "inbox_id") if err != nil { diff --git a/internal/handler/api/v1/inbox_handler_parity_test.go b/internal/handler/api/v1/inbox_handler_parity_test.go index 9d18198a..586bc035 100644 --- a/internal/handler/api/v1/inbox_handler_parity_test.go +++ b/internal/handler/api/v1/inbox_handler_parity_test.go @@ -2,7 +2,9 @@ package v1 import ( "bytes" + "context" "encoding/json" + "errors" "fmt" "mime/multipart" "net/http" @@ -15,6 +17,7 @@ import ( "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" @@ -118,6 +121,96 @@ func TestInboxHandler_ChatwootSerializerParity(t *testing.T) { require.Equal(t, "Your inbox deletion request will be processed in some time.", inboxParityObject(t, destroy)["message"]) } +func TestInboxHandler_HealthReturnsWhatsAppCloudRawPayload(t *testing.T) { + gin.SetMode(gin.TestMode) + + db, err := gorm.Open(sqlite.Open("file:inbox_handler_health_parity?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{})) + + account := &model.Account{Name: "Inbox Health", 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-123", + BusinessAccountID: "waba-456", + AccessToken: "token-789", + Provider: "whatsapp_cloud", + } + require.NoError(t, db.Create(channel).Error) + + router := setupInboxParityRouterWithWhatsAppService(db, &fakeInboxHealthWhatsAppService{payload: map[string]interface{}{ + "id": "phone-123", + "quality_rating": "GREEN", + "expected_webhook_url": "https://app.test/webhooks/whatsapp/+1555010000", + "business_id": "waba-456", + }}) + + response := inboxParityRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d/health", account.ID, inbox.ID), nil) + require.Equal(t, http.StatusOK, response.Code, response.Body.String()) + payload := inboxParityObject(t, response) + require.Equal(t, "phone-123", payload["id"]) + require.Equal(t, "GREEN", payload["quality_rating"]) + require.Equal(t, "https://app.test/webhooks/whatsapp/+1555010000", payload["expected_webhook_url"]) + require.Equal(t, "waba-456", payload["business_id"]) + require.NotContains(t, payload, "success") + require.NotContains(t, payload, "status") + require.NotContains(t, payload, "healthy") +} + +func TestInboxHandler_HealthReturnsProviderFailureState(t *testing.T) { + gin.SetMode(gin.TestMode) + + db, err := gorm.Open(sqlite.Open("file:inbox_handler_health_failure?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{})) + + account := &model.Account{Name: "Inbox Health Failure", 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) + wa := &channelmodel.ChannelWhatsApp{ + InboxID: inbox.ID, + PhoneNumber: "+1555010001", + PhoneNumberID: "phone-number-id", + BusinessAccountID: "business-account-id", + Provider: "whatsapp_cloud", + AccessToken: "access-token", + } + require.NoError(t, db.Create(wa).Error) + + router := setupInboxParityRouterWithWhatsAppService(db, &fakeInboxHealthWhatsAppService{err: errors.New("provider unavailable")}) + response := inboxParityRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d/health", account.ID, inbox.ID), nil) + require.Equal(t, http.StatusInternalServerError, response.Code, response.Body.String()) + + var payload map[string]interface{} + require.NoError(t, json.Unmarshal(response.Body.Bytes(), &payload)) + require.Equal(t, false, payload["success"]) + errorBody := payload["error"].(map[string]interface{}) + require.Equal(t, "INTERNAL_ERROR", errorBody["code"]) + require.Contains(t, errorBody["message"], "provider unavailable") +} + func TestSerializeInboxIncludesTwitterTweetsEnabled(t *testing.T) { defaultInbox := &model.Inbox{AccountID: 1, Name: "Twitter", ChannelType: "Channel::TwitterProfile"} defaultPayload := serializeInbox(defaultInbox, nil, false) @@ -535,7 +628,11 @@ func TestInboxHandler_ChatwootChannelSpecificConfigDepth(t *testing.T) { } func setupInboxParityRouter(db *gorm.DB) *gin.Engine { - inboxSvc := service.NewInboxService(repository.NewInboxRepo(db), nil, nil, nil, nil, nil, nil) + return setupInboxParityRouterWithWhatsAppService(db, nil) +} + +func setupInboxParityRouterWithWhatsAppService(db *gorm.DB, whatsappService service.WhatsAppChannelService) *gin.Engine { + inboxSvc := service.NewInboxService(repository.NewInboxRepo(db), nil, nil, nil, nil, whatsappService, whatsappchannel.NewRepository(db)) handler := NewInboxHandler(inboxSvc) router := gin.New() router.Use(func(c *gin.Context) { @@ -555,10 +652,39 @@ func setupInboxParityRouter(db *gorm.DB) *gin.Engine { inboxes.PATCH("/:inbox_id", handler.Update) inboxes.DELETE("/:inbox_id", handler.Delete) inboxes.DELETE("/:inbox_id/avatar", handler.DeleteAvatar) + inboxes.GET("/:inbox_id/health", handler.Health) } return router } +type fakeInboxHealthWhatsAppService struct { + payload map[string]interface{} + err error +} + +func (f *fakeInboxHealthWhatsAppService) FetchMessageTemplates(context.Context, *channelmodel.ChannelWhatsApp) ([]interface{}, error) { + return nil, nil +} + +func (f *fakeInboxHealthWhatsAppService) FetchHealthStatus(context.Context, *channelmodel.ChannelWhatsApp) (map[string]interface{}, error) { + if f.err != nil { + return nil, f.err + } + return f.payload, nil +} + +func (f *fakeInboxHealthWhatsAppService) SetupWebhook(context.Context, *channelmodel.ChannelWhatsApp, string) error { + return nil +} + +func (f *fakeInboxHealthWhatsAppService) SetupWebhookFields(context.Context, *channelmodel.ChannelWhatsApp, string, []string) error { + return nil +} + +func (f *fakeInboxHealthWhatsAppService) UpdateCallingStatus(context.Context, *channelmodel.ChannelWhatsApp, string) error { + return nil +} + func inboxParityRequest(t *testing.T, router *gin.Engine, method string, path string, body any) *httptest.ResponseRecorder { return inboxParityRequestWithRole(t, router, method, path, body, "") } diff --git a/internal/handler/api/v1/integration_hook_handler_suite_test.go b/internal/handler/api/v1/integration_hook_handler_suite_test.go index 40cce23b..1273e8bf 100644 --- a/internal/handler/api/v1/integration_hook_handler_suite_test.go +++ b/internal/handler/api/v1/integration_hook_handler_suite_test.go @@ -135,6 +135,53 @@ func (s *IntegrationHookHandlerSuite) TestListApps_Empty() { s.Len(data, 0) } +func (s *IntegrationHookHandlerSuite) TestChatwootFrontendAppsAndHooksNoTrailingSlashPayloads() { + s.createApp("Webhook App", model.HookTypeWebhook) + + appsRecorder := httptest.NewRecorder() + appsReq, _ := http.NewRequest(http.MethodGet, "/api/v1/accounts/1/integrations/apps", nil) + s.router.ServeHTTP(appsRecorder, appsReq) + s.Equal(http.StatusOK, appsRecorder.Code) + + var appsBody map[string]interface{} + s.Require().NoError(json.Unmarshal(appsRecorder.Body.Bytes(), &appsBody)) + apps := appsBody["payload"].([]interface{}) + s.Require().Len(apps, 1) + app := apps[0].(map[string]interface{}) + s.Equal("webhook", app["id"]) + s.Equal("Webhook App", app["name"]) + s.Equal("account", app["hook_type"]) + s.Contains(app, "hooks") + + createBody, _ := json.Marshal(map[string]interface{}{ + "app_id": "webhook", + "settings": map[string]interface{}{ + "project_id": "chatwoot-front-end", + }, + }) + createRecorder := httptest.NewRecorder() + createReq, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/1/integrations/hooks", bytes.NewBuffer(createBody)) + createReq.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(createRecorder, createReq) + s.Equal(http.StatusOK, createRecorder.Code) + + var created map[string]interface{} + s.Require().NoError(json.Unmarshal(createRecorder.Body.Bytes(), &created)) + s.Equal("webhook", created["app_id"]) + s.Equal("account", created["hook_type"]) + s.Equal(true, created["status"]) + settings := created["settings"].(map[string]interface{}) + s.Equal("chatwoot-front-end", settings["project_id"]) + s.NotContains(created, "success") + + hookID := strconv.FormatUint(uint64(created["id"].(float64)), 10) + deleteRecorder := httptest.NewRecorder() + deleteReq, _ := http.NewRequest(http.MethodDelete, "/api/v1/accounts/1/integrations/hooks/"+hookID, nil) + s.router.ServeHTTP(deleteRecorder, deleteReq) + s.Equal(http.StatusOK, deleteRecorder.Code) + s.Empty(deleteRecorder.Body.String()) +} + // ===================== // GetApp tests // ===================== diff --git a/internal/handler/api/v1/label_handler.go b/internal/handler/api/v1/label_handler.go index 10a986ff..a14c4193 100644 --- a/internal/handler/api/v1/label_handler.go +++ b/internal/handler/api/v1/label_handler.go @@ -191,7 +191,7 @@ func (h *LabelHandler) AddLabelToConversation(c *gin.Context) { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } - conversationID, err := parseUintParam(c, "id") + conversationID, err := parseUintAnyParam(c, "id", "conversation_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation id") return @@ -218,7 +218,7 @@ func (h *LabelHandler) AddLabelToConversation(c *gin.Context) { // RemoveLabelFromConversation detaches a label from a conversation. // DELETE /api/v1/accounts/:account_id/conversations/:id/labels/:tag_id func (h *LabelHandler) RemoveLabelFromConversation(c *gin.Context) { - conversationID, err := parseUintParam(c, "id") + conversationID, err := parseUintAnyParam(c, "id", "conversation_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation id") return @@ -241,7 +241,7 @@ func (h *LabelHandler) RemoveLabelFromConversation(c *gin.Context) { // GetConversationLabels returns all labels on a conversation. // GET /api/v1/accounts/:account_id/conversations/:id/labels func (h *LabelHandler) GetConversationLabels(c *gin.Context) { - conversationID, err := parseUintParam(c, "id") + conversationID, err := parseUintAnyParam(c, "id", "conversation_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation id") return @@ -265,7 +265,7 @@ func (h *LabelHandler) ReplaceConversationLabels(c *gin.Context) { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } - conversationID, err := parseUintParam(c, "id") + conversationID, err := parseUintAnyParam(c, "id", "conversation_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation id") return diff --git a/internal/handler/api/v1/line_channel_handler_test.go b/internal/handler/api/v1/line_channel_handler_test.go index 0d418594..5ee55dca 100644 --- a/internal/handler/api/v1/line_channel_handler_test.go +++ b/internal/handler/api/v1/line_channel_handler_test.go @@ -119,6 +119,25 @@ func TestLINEChannel_Create_Success(t *testing.T) { assert.Equal(t, "access_token_secret", resp["line_channel_token"]) require.NotContains(t, resp, "channel") require.NotContains(t, resp, "inbox") + + var inbox model.Inbox + require.NoError(t, db.First(&inbox, uint(resp["id"].(float64))).Error) + assert.Equal(t, "LINE Official", inbox.Name) + assert.Equal(t, "line", inbox.ChannelType) + assert.Equal(t, uint(resp["channel_id"].(float64)), inbox.ChannelID) + var config map[string]any + require.NoError(t, json.Unmarshal([]byte(inbox.ChannelConfig), &config)) + assert.Equal(t, "line_chan_123", config["line_channel_id"]) + assert.Equal(t, "secret_value", config["line_channel_secret"]) + assert.Equal(t, "access_token_secret", config["line_channel_token"]) + assert.Equal(t, "line_chan_123", config["channel_id"]) + assert.Equal(t, "secret_value", config["channel_secret"]) + assert.Equal(t, "access_token_secret", config["channel_access_token"]) + + var channel channelmodel.ChannelLINE + require.NoError(t, db.First(&channel, inbox.ChannelID).Error) + assert.Equal(t, inbox.ID, channel.InboxID) + assert.Equal(t, "line_chan_123", channel.ChannelID) } func TestLINEChannel_CreateRejectsAccountInboxLimitWithoutChannelOrphan(t *testing.T) { diff --git a/internal/handler/api/v1/linear_integration_handler_test.go b/internal/handler/api/v1/linear_integration_handler_test.go index c3a9bbed..869a7839 100644 --- a/internal/handler/api/v1/linear_integration_handler_test.go +++ b/internal/handler/api/v1/linear_integration_handler_test.go @@ -3,14 +3,35 @@ package v1 import ( "bytes" "encoding/json" + "io" "net/http" "net/http/httptest" + "strings" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/datatypes" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/gochat/gochat/internal/model" + "github.com/gochat/gochat/internal/repository" + "github.com/gochat/gochat/internal/service" ) +type linearHandlerRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f linearHandlerRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func linearHandlerJSONResponse(body string) *http.Response { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))} +} + func setupLinearIntegrationRouter() *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() @@ -24,6 +45,73 @@ func setupLinearIntegrationRouter() *gin.Engine { return r } +func setupLinearIntegrationRouterWithService(svc *service.LinearIntegrationService) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.RedirectTrailingSlash = false + + handler := NewLinearIntegrationHandler(svc) + integrations := r.Group("/api/v1/accounts/:account_id/integrations") + RegisterLinearIntegrationRoutes(integrations, handler) + return r +} + +func setupLinearIntegrationHandlerFixture(t *testing.T) (*gorm.DB, uint) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:linear_handler_success?mode=memory&cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + t.Cleanup(func() { sqlDB, _ := db.DB(); _ = sqlDB.Close() }) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.User{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, &model.Message{}, &model.IntegrationHook{})) + account := &model.Account{Name: "Linear Handler Account", Locale: "en", Status: "active"} + require.NoError(t, db.Create(account).Error) + user := &model.User{AccountID: account.ID, Name: "Linear Agent", Email: "linear-agent@example.test", Role: "agent"} + require.NoError(t, db.Create(user).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "Website", ChannelType: "web_widget", Enabled: true} + require.NoError(t, db.Create(inbox).Error) + contact := &model.Contact{AccountID: account.ID, Name: "Visitor"} + require.NoError(t, db.Create(contact).Error) + displayID := uint(1) + conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"} + require.NoError(t, db.Create(conversation).Error) + settings := datatypes.JSON([]byte(`{"access_token":"lin-handler-token","refresh_token":"refresh-token"}`)) + require.NoError(t, db.Create(&model.IntegrationHook{AccountID: account.ID, AppID: "linear", HookType: model.HookTypeLinear, AccessToken: "lin-handler-token", Settings: settings, Status: model.HookStatusActive}).Error) + return db, account.ID +} + +func setupLinearIntegrationSuccessRouter(t *testing.T) *gin.Engine { + t.Helper() + db, _ := setupLinearIntegrationHandlerFixture(t) + svc := service.NewLinearIntegrationService(repository.NewIntegrationHookRepo(db), service.WithLinearHTTPClient("https://linear.test", &http.Client{Transport: linearHandlerRoundTripFunc(func(req *http.Request) (*http.Response, error) { + require.Equal(t, "/graphql", req.URL.Path) + require.Equal(t, "Bearer lin-handler-token", req.Header.Get("Authorization")) + raw, err := io.ReadAll(req.Body) + require.NoError(t, err) + var payload map[string]string + require.NoError(t, json.Unmarshal(raw, &payload)) + query := payload["query"] + switch { + case strings.Contains(query, "teams"): + return linearHandlerJSONResponse(`{"data":{"teams":{"nodes":[{"id":"team-1","name":"Support"}]}}}`), nil + case strings.Contains(query, "workflowStates") && strings.Contains(query, "issueLabels"): + return linearHandlerJSONResponse(`{"data":{"users":{"nodes":[{"id":"user-1","name":"Agent"}]},"projects":{"nodes":[{"id":"project-1","name":"Inbox"}]},"workflowStates":{"nodes":[{"id":"state-1","name":"Todo"}]},"issueLabels":{"nodes":[{"id":"label-1","name":"Bug"}]}}}`), nil + case strings.Contains(query, "issueCreate"): + return linearHandlerJSONResponse(`{"data":{"issueCreate":{"success":true,"issue":{"id":"issue-1","title":"Bug","identifier":"ENG-1"}}}}`), nil + case strings.Contains(query, "attachmentLinkURL"): + return linearHandlerJSONResponse(`{"data":{"attachmentLinkURL":{"success":true,"attachment":{"id":"link-1"}}}}`), nil + case strings.Contains(query, "attachmentDelete"): + return linearHandlerJSONResponse(`{"data":{"attachmentDelete":{"success":true}}}`), nil + case strings.Contains(query, "searchIssues"): + return linearHandlerJSONResponse(`{"data":{"searchIssues":{"nodes":[{"id":"issue-1","identifier":"ENG-1","title":"Bug"}]}}}`), nil + case strings.Contains(query, "attachmentsForURL"): + return linearHandlerJSONResponse(`{"data":{"attachmentsForURL":{"nodes":[{"id":"link-1","title":"Bug","issue":{"id":"issue-1","identifier":"ENG-1","title":"Bug"}}]}}}`), nil + default: + t.Fatalf("unexpected Linear GraphQL query: %s", query) + } + return nil, nil + })})) + return setupLinearIntegrationRouterWithService(svc) +} + // ======================================== // LinearIntegration — param validation tests // ======================================== @@ -209,3 +297,99 @@ func TestLinearIntegration_GetLinkedIssues_BadAccountID(t *testing.T) { errBody := resp["error"].(map[string]interface{}) assert.Contains(t, errBody["message"], "invalid account_id") } + +func TestLinearIntegration_ChatwootFrontendRuntimeRoutes(t *testing.T) { + r := setupLinearIntegrationSuccessRouter(t) + + t.Run("teams", func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/accounts/1/integrations/linear/teams", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp []map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Len(t, resp, 1) + assert.Equal(t, "team-1", resp[0]["id"]) + assert.Equal(t, "Support", resp[0]["name"]) + }) + + t.Run("team_entities", func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/accounts/1/integrations/linear/team_entities?team_id=team-1", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string][]map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "user-1", resp["users"][0]["id"]) + assert.Equal(t, "project-1", resp["projects"][0]["id"]) + assert.Equal(t, "state-1", resp["states"][0]["id"]) + assert.Equal(t, "label-1", resp["labels"][0]["id"]) + }) + + t.Run("create_issue", func(t *testing.T) { + w := httptest.NewRecorder() + body := bytes.NewReader([]byte(`{"title":"Bug","team_id":"team-1","conversation_id":1}`)) + req, _ := http.NewRequest("POST", "/api/v1/accounts/1/integrations/linear/create_issue", body) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "issue-1", resp["id"]) + assert.Equal(t, "ENG-1", resp["identifier"]) + }) + + t.Run("link_issue", func(t *testing.T) { + w := httptest.NewRecorder() + body := bytes.NewReader([]byte(`{"issue_id":"issue-1","conversation_id":1,"title":"Bug"}`)) + req, _ := http.NewRequest("POST", "/api/v1/accounts/1/integrations/linear/link_issue", body) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "issue-1", resp["id"]) + assert.Equal(t, "link-1", resp["link_id"]) + }) + + t.Run("unlink_issue", func(t *testing.T) { + w := httptest.NewRecorder() + body := bytes.NewReader([]byte(`{"link_id":"link-1","issue_id":"issue-1","conversation_id":1}`)) + req, _ := http.NewRequest("POST", "/api/v1/accounts/1/integrations/linear/unlink_issue", body) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "link-1", resp["link_id"]) + }) + + t.Run("search_issue", func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/accounts/1/integrations/linear/search_issue?q=query", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp []map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Len(t, resp, 1) + assert.Equal(t, "ENG-1", resp[0]["identifier"]) + }) + + t.Run("linked_issues", func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/accounts/1/integrations/linear/linked_issues?conversation_id=1", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp []map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Len(t, resp, 1) + assert.Equal(t, "link-1", resp[0]["id"]) + }) +} diff --git a/internal/handler/api/v1/live_report_handler_test.go b/internal/handler/api/v1/live_report_handler_test.go index 026196aa..6f2a8252 100644 --- a/internal/handler/api/v1/live_report_handler_test.go +++ b/internal/handler/api/v1/live_report_handler_test.go @@ -26,7 +26,7 @@ type LiveReportHandlerTestSuite struct { func (s *LiveReportHandlerTestSuite) SetupSuite() { s.db, _ = gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - s.db.AutoMigrate(&model.Account{}, &model.Team{}, &model.Conversation{}, &model.ReportingEventsRollup{}) + s.db.AutoMigrate(&model.Account{}, &model.Team{}, &model.Conversation{}, &model.Message{}, &model.ReportingEventsRollup{}) anSvc := service.NewAnalyticsService( repository.NewReportingEventRepo(s.db), @@ -38,10 +38,13 @@ func (s *LiveReportHandlerTestSuite) SetupSuite() { r := gin.New() r.GET("/api/v1/accounts/:account_id/live_reports/conversation_metrics", s.handler.ConversationMetrics) r.GET("/api/v1/accounts/:account_id/live_reports/grouped_conversation_metrics", s.handler.GroupedConversationMetrics) + r.GET("/api/v2/accounts/:account_id/live_reports/conversation_metrics", s.handler.ConversationMetrics) + r.GET("/api/v2/accounts/:account_id/live_reports/grouped_conversation_metrics", s.handler.GroupedConversationMetrics) s.router = r } func (s *LiveReportHandlerTestSuite) SetupTest() { + s.db.Exec("DELETE FROM messages") s.db.Exec("DELETE FROM conversations") s.db.Exec("DELETE FROM teams") } @@ -71,6 +74,141 @@ func (s *LiveReportHandlerTestSuite) TestConversationMetrics_Success() { s.Equal(float64(0), body["pending"]) } +func (s *LiveReportHandlerTestSuite) TestAPIV2LiveReports_ChatwootFrontendPayloadShapes() { + team := model.Team{AccountID: 1, Name: "Support"} + otherTeam := model.Team{AccountID: 1, Name: "Other"} + s.Require().NoError(s.db.Create(&team).Error) + s.Require().NoError(s.db.Create(&otherTeam).Error) + agentID := uint(7) + otherAgentID := uint(8) + firstReply := int64(1760000000) + waitingSince := int64(1760000100) + teamOpen := model.Conversation{AccountID: 1, TeamID: &team.ID, AssigneeID: &agentID, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget"} + teamReplied := model.Conversation{AccountID: 1, TeamID: &team.ID, AssigneeID: &agentID, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", FirstReplyCreatedAt: &firstReply} + teamWaiting := model.Conversation{AccountID: 1, TeamID: &team.ID, AssigneeID: &otherAgentID, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", FirstReplyCreatedAt: &firstReply, WaitingSince: &waitingSince} + teamPending := model.Conversation{AccountID: 1, TeamID: &team.ID, AssigneeID: &agentID, Status: string(model.ConversationStatusPending), ChannelType: "web_widget", Channel: "web_widget"} + unassigned := model.Conversation{AccountID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget"} + otherTeamOpen := model.Conversation{AccountID: 1, TeamID: &otherTeam.ID, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget"} + otherAccountOpen := model.Conversation{AccountID: 2, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget"} + s.Require().NoError(s.db.Create(&teamOpen).Error) + s.Require().NoError(s.db.Create(&teamReplied).Error) + s.Require().NoError(s.db.Create(&teamWaiting).Error) + s.Require().NoError(s.db.Create(&teamPending).Error) + s.Require().NoError(s.db.Create(&unassigned).Error) + s.Require().NoError(s.db.Create(&otherTeamOpen).Error) + s.Require().NoError(s.db.Create(&otherAccountOpen).Error) + s.Require().NoError(s.db.Create(&model.Message{AccountID: 1, ConversationID: teamReplied.ID, MessageType: string(model.MessageTypeOutgoing), Content: "already handled"}).Error) + + metrics := httptest.NewRecorder() + s.router.ServeHTTP(metrics, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/live_reports/conversation_metrics", nil)) + s.Equal(http.StatusOK, metrics.Code) + metricsBody := decodeLiveReportObject(s.T(), metrics.Body.String()) + assertLiveReportConversationMetricsShape(s.T(), metricsBody) + s.Equal(float64(5), metricsBody["open"]) + s.Equal(float64(4), metricsBody["unattended"]) + s.Equal(float64(2), metricsBody["unassigned"]) + s.Equal(float64(1), metricsBody["pending"]) + s.NotContains(metricsBody, "success") + + teamMetrics := httptest.NewRecorder() + s.router.ServeHTTP(teamMetrics, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/live_reports/conversation_metrics?team_id="+strconv.FormatUint(uint64(team.ID), 10), nil)) + s.Equal(http.StatusOK, teamMetrics.Code) + teamMetricsBody := decodeLiveReportObject(s.T(), teamMetrics.Body.String()) + assertLiveReportConversationMetricsShape(s.T(), teamMetricsBody) + s.Equal(float64(3), teamMetricsBody["open"]) + s.Equal(float64(2), teamMetricsBody["unattended"]) + s.Equal(float64(0), teamMetricsBody["unassigned"]) + s.Equal(float64(1), teamMetricsBody["pending"]) + + groupedByAssignee := httptest.NewRecorder() + s.router.ServeHTTP(groupedByAssignee, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/live_reports/grouped_conversation_metrics?group_by=assignee_id", nil)) + s.Equal(http.StatusOK, groupedByAssignee.Code) + assigneeRows := decodeLiveReportArray(s.T(), groupedByAssignee.Body.String()) + s.Require().Len(assigneeRows, 3) + assertLiveReportGroupedMetricShape(s.T(), assigneeRows[0], "assignee_id") + s.Nil(assigneeRows[0]["assignee_id"]) + s.Equal(float64(2), assigneeRows[0]["open"]) + s.Equal(float64(2), assigneeRows[0]["unattended"]) + s.Equal(float64(2), assigneeRows[0]["unassigned"]) + s.Equal(float64(agentID), assigneeRows[1]["assignee_id"]) + s.Equal(float64(2), assigneeRows[1]["open"]) + s.Equal(float64(1), assigneeRows[1]["unattended"]) + s.Equal(float64(otherAgentID), assigneeRows[2]["assignee_id"]) + s.Equal(float64(1), assigneeRows[2]["open"]) + s.Equal(float64(1), assigneeRows[2]["unattended"]) + + groupedByTeam := httptest.NewRecorder() + s.router.ServeHTTP(groupedByTeam, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/live_reports/grouped_conversation_metrics?group_by=team_id", nil)) + s.Equal(http.StatusOK, groupedByTeam.Code) + teamRows := decodeLiveReportArray(s.T(), groupedByTeam.Body.String()) + s.Require().Len(teamRows, 3) + assertLiveReportGroupedMetricShape(s.T(), teamRows[0], "team_id") + s.Nil(teamRows[0]["team_id"]) + s.Equal(float64(1), teamRows[0]["open"]) + s.Equal(float64(1), teamRows[0]["unattended"]) + s.Equal(float64(team.ID), teamRows[1]["team_id"]) + s.Equal(float64(3), teamRows[1]["open"]) + s.Equal(float64(2), teamRows[1]["unattended"]) + s.Equal(float64(otherTeam.ID), teamRows[2]["team_id"]) + s.Equal(float64(1), teamRows[2]["open"]) + + invalidGroup := httptest.NewRecorder() + s.router.ServeHTTP(invalidGroup, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/live_reports/grouped_conversation_metrics?group_by=bad", nil)) + s.Equal(http.StatusUnprocessableEntity, invalidGroup.Code) + errorBody := decodeLiveReportObject(s.T(), invalidGroup.Body.String()) + s.Equal("invalid group_by", errorBody["error"]) + s.NotContains(errorBody, "success") +} + +func (s *LiveReportHandlerTestSuite) TestAPIV2LiveReports_StoreRefreshSequenceMatchesChatwootFrontend() { + team := model.Team{AccountID: 1, Name: "Store Team"} + s.Require().NoError(s.db.Create(&team).Error) + agentID := uint(11) + teamOpen := model.Conversation{AccountID: 1, TeamID: &team.ID, AssigneeID: &agentID, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget"} + unassigned := model.Conversation{AccountID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget"} + pending := model.Conversation{AccountID: 1, TeamID: &team.ID, AssigneeID: &agentID, Status: string(model.ConversationStatusPending), ChannelType: "web_widget", Channel: "web_widget"} + otherAccount := model.Conversation{AccountID: 2, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget"} + s.Require().NoError(s.db.Create(&teamOpen).Error) + s.Require().NoError(s.db.Create(&unassigned).Error) + s.Require().NoError(s.db.Create(&pending).Error) + s.Require().NoError(s.db.Create(&otherAccount).Error) + + accountMetric := httptest.NewRecorder() + s.router.ServeHTTP(accountMetric, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/live_reports/conversation_metrics", nil)) + s.Equal(http.StatusOK, accountMetric.Code) + accountPayload := decodeLiveReportObject(s.T(), accountMetric.Body.String()) + s.Equal(float64(2), accountPayload["open"]) + s.Equal(float64(2), accountPayload["unattended"]) + s.Equal(float64(1), accountPayload["unassigned"]) + s.Equal(float64(1), accountPayload["pending"]) + s.NotContains(accountPayload, "payload") + s.NotContains(accountPayload, "success") + + agentMetric := httptest.NewRecorder() + s.router.ServeHTTP(agentMetric, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/live_reports/grouped_conversation_metrics?group_by=assignee_id", nil)) + s.Equal(http.StatusOK, agentMetric.Code) + agentRows := decodeLiveReportArray(s.T(), agentMetric.Body.String()) + s.Require().Len(agentRows, 2) + s.Nil(agentRows[0]["assignee_id"]) + s.Equal(float64(1), agentRows[0]["open"]) + s.Equal(float64(agentID), agentRows[1]["assignee_id"]) + s.Equal(float64(1), agentRows[1]["open"]) + s.Equal(float64(1), agentRows[1]["unattended"]) + s.Equal(float64(0), agentRows[1]["unassigned"]) + + teamMetric := httptest.NewRecorder() + s.router.ServeHTTP(teamMetric, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/live_reports/grouped_conversation_metrics?group_by=team_id", nil)) + s.Equal(http.StatusOK, teamMetric.Code) + teamRows := decodeLiveReportArray(s.T(), teamMetric.Body.String()) + s.Require().Len(teamRows, 2) + s.Nil(teamRows[0]["team_id"]) + s.Equal(float64(1), teamRows[0]["open"]) + s.Equal(float64(team.ID), teamRows[1]["team_id"]) + s.Equal(float64(1), teamRows[1]["open"]) + s.Equal(float64(1), teamRows[1]["unattended"]) + s.Equal(float64(0), teamRows[1]["unassigned"]) +} + func (s *LiveReportHandlerTestSuite) TestGroupedConversationMetrics_InvalidGroupByReturnsChatwootError() { w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/live_reports/grouped_conversation_metrics?group_by=invalid_param", nil) @@ -132,3 +270,39 @@ func (s *LiveReportHandlerTestSuite) TestGroupedConversationMetrics_FiltersByTea s.Equal(float64(1), body[0]["unattended"]) s.Equal(float64(0), body[0]["unassigned"]) } + +func decodeLiveReportObject(t *testing.T, body string) map[string]interface{} { + t.Helper() + payload := map[string]interface{}{} + if err := json.Unmarshal([]byte(body), &payload); err != nil { + t.Fatalf("expected JSON object: %v\n%s", err, body) + } + return payload +} + +func decodeLiveReportArray(t *testing.T, body string) []map[string]interface{} { + t.Helper() + payload := []map[string]interface{}{} + if err := json.Unmarshal([]byte(body), &payload); err != nil { + t.Fatalf("expected JSON array: %v\n%s", err, body) + } + return payload +} + +func assertLiveReportConversationMetricsShape(t *testing.T, payload map[string]interface{}) { + t.Helper() + for _, key := range []string{"open", "unattended", "unassigned", "pending"} { + if _, ok := payload[key]; !ok { + t.Fatalf("expected live conversation metrics to include %q, got %#v", key, payload) + } + } +} + +func assertLiveReportGroupedMetricShape(t *testing.T, payload map[string]interface{}, groupKey string) { + t.Helper() + for _, key := range []string{groupKey, "open", "unattended", "unassigned"} { + if _, ok := payload[key]; !ok { + t.Fatalf("expected live grouped metric to include %q, got %#v", key, payload) + } + } +} diff --git a/internal/handler/api/v1/macro_handler_test.go b/internal/handler/api/v1/macro_handler_test.go index 5260e3ce..ab68abf9 100644 --- a/internal/handler/api/v1/macro_handler_test.go +++ b/internal/handler/api/v1/macro_handler_test.go @@ -279,3 +279,56 @@ func (s *MacroHandlerTestSuite) TestExecute_UsesConversationDisplayIDsAndMutates assert.Equal(s.T(), "Internal macro note", messages[1].Content) assert.True(s.T(), messages[1].Private) } + +func (s *MacroHandlerTestSuite) TestExecute_ChatwootFrontendAwaitsEmptyOKAndSupportsSingleConversationID() { + admin := &model.User{Name: "Exec Single Admin", Email: "macro-exec-single@example.com", Role: "administrator"} + s.Require().NoError(s.db.Create(admin).Error) + inbox := &model.Inbox{AccountID: s.account.ID, Name: "Macro Single Inbox", ChannelType: "web"} + s.Require().NoError(s.db.Create(inbox).Error) + contact := &model.Contact{AccountID: s.account.ID, Name: "Macro Single Contact", Email: "macro-single@example.com"} + s.Require().NoError(s.db.Create(contact).Error) + displayID := uint(555) + conversation := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open", Priority: "low", ChannelType: "web", Channel: "web_widget"} + s.Require().NoError(s.db.Create(conversation).Error) + macro := &automation.Macro{ + AccountID: s.account.ID, + Name: "single execute macro", + Actions: automation.Actions{ + {ActionName: "change_status", ActionParams: map[string]interface{}{"status": "resolved"}}, + }, + Visibility: automation.MacroVisibilityGlobal, + Active: true, + CreatedByID: admin.ID, + UpdatedByID: admin.ID, + } + s.Require().NoError(s.db.Create(macro).Error) + + r := gin.New() + r.POST("/api/v1/accounts/:account_id/macros/:macro_id/execute", func(c *gin.Context) { + c.Set("user_id", admin.ID) + c.Set("role", "administrator") + c.Next() + }, s.handler.Execute) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/macros/%d/execute", s.account.ID, macro.ID), bytes.NewBufferString(`{"conversation_id":555}`)) + 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 reloaded model.Conversation + s.Require().NoError(s.db.First(&reloaded, conversation.ID).Error) + assert.Equal(s.T(), "resolved", reloaded.Status) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/macros/%d/execute", s.account.ID, macro.ID), nil) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + assert.Equal(s.T(), http.StatusBadRequest, w.Code) + var errorResp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &errorResp)) + validationError := errorResp["error"].(map[string]interface{}) + assert.Equal(s.T(), "VALIDATION_ERROR", validationError["code"]) + assert.Equal(s.T(), "conversation_ids is required", validationError["message"]) +} diff --git a/internal/handler/api/v1/message_handler_test.go b/internal/handler/api/v1/message_handler_test.go index 3ed14fe2..5335827a 100644 --- a/internal/handler/api/v1/message_handler_test.go +++ b/internal/handler/api/v1/message_handler_test.go @@ -62,6 +62,7 @@ type MessageHandlerTestSuite struct { testContact *model.Contact testConv *model.Conversation testMessage *model.Message + testUser *model.User mockLLM *mockMsgHandlerLLMProvider dispatcher *channel.Dispatcher } @@ -78,6 +79,7 @@ func (s *MessageHandlerTestSuite) SetupSuite() { // AutoMigrate all required models err = db.AutoMigrate( &model.Account{}, + &model.User{}, &model.Inbox{}, &model.Contact{}, &model.ContactInbox{}, @@ -111,7 +113,11 @@ func (s *MessageHandlerTestSuite) SetupSuite() { // Auth middleware: inject user_id into context for all message routes r.Use(func(c *gin.Context) { - c.Set("user_id", uint(1)) + userID := uint(1) + if s.testUser != nil { + userID = s.testUser.ID + } + c.Set("user_id", userID) c.Next() }) @@ -146,6 +152,10 @@ func (s *MessageHandlerTestSuite) SetupTest() { s.Require().NoError(s.db.Create(account).Error) s.testAccount = account + user := &model.User{AccountID: account.ID, Name: "Msg Handler Agent", DisplayName: "Message Agent", Email: "message-agent@example.com", Provider: "email", PubsubToken: "pubsub-message-agent"} + s.Require().NoError(s.db.Create(user).Error) + s.testUser = user + inbox := &model.Inbox{AccountID: account.ID, Name: "MsgHandlerTestInbox", ChannelType: "web_widget", ChannelID: 1} s.Require().NoError(s.db.Create(inbox).Error) s.testInbox = inbox @@ -200,6 +210,7 @@ func (s *MessageHandlerTestSuite) TearDownTest() { s.db.Exec("DELETE FROM contact_inboxes") s.db.Exec("DELETE FROM contacts") s.db.Exec("DELETE FROM inbox_members") + s.db.Exec("DELETE FROM users") s.db.Exec("DELETE FROM inboxes") s.db.Exec("DELETE FROM accounts") } @@ -387,11 +398,25 @@ func (s *MessageHandlerTestSuite) TestCreate_ChatwootFrontendPayloadDefaultsOutg var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) assert.Equal(s.T(), "Frontend payload", resp["content"]) + assert.Equal(s.T(), float64(s.testAccount.ID), resp["account_id"]) + assert.Equal(s.T(), float64(s.testInbox.ID), resp["inbox_id"]) + assert.Equal(s.T(), float64(*s.testConv.DisplayID), resp["conversation_id"]) assert.Equal(s.T(), true, resp["private"]) assert.Equal(s.T(), "tmp-123", resp["echo_id"]) assert.Equal(s.T(), float64(1), resp["message_type"]) assert.Equal(s.T(), "text", resp["content_type"]) - assert.NotNil(s.T(), resp["content_attributes"]) + assert.Equal(s.T(), "sent", resp["status"]) + assert.Equal(s.T(), "", resp["source_id"]) + assert.NotContains(s.T(), resp, "success") + contentAttrs, ok := resp["content_attributes"].(map[string]interface{}) + s.Require().True(ok) + assert.Equal(s.T(), []interface{}{}, contentAttrs["submitted_values"]) + sender, ok := resp["sender"].(map[string]interface{}) + s.Require().True(ok) + assert.Equal(s.T(), float64(s.testUser.ID), sender["id"]) + assert.Equal(s.T(), "Msg Handler Agent", sender["name"]) + assert.Equal(s.T(), "Message Agent", sender["available_name"]) + assert.Equal(s.T(), "message-agent@example.com", sender["email"]) } func (s *MessageHandlerTestSuite) TestCreate_MultipartAttachmentPersistsAndSerializes() { @@ -524,7 +549,7 @@ func (s *MessageHandlerTestSuite) TestUpdate_StatusExternalError() { body, _ := json.Marshal(payload) w := httptest.NewRecorder() - url := msgDetailURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID) + url := msgDetailURL(s.testAccount.ID, *s.testConv.DisplayID, s.testMessage.ID) req, _ := http.NewRequest("PATCH", url, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) @@ -542,7 +567,7 @@ func (s *MessageHandlerTestSuite) TestUpdate_StatusForbiddenForNonAPIInbox() { body, _ := json.Marshal(payload) w := httptest.NewRecorder() - url := msgDetailURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID) + url := msgDetailURL(s.testAccount.ID, *s.testConv.DisplayID, s.testMessage.ID) req, _ := http.NewRequest("PATCH", url, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) @@ -572,7 +597,7 @@ func (s *MessageHandlerTestSuite) TestDelete_Success() { s.Require().NoError(s.db.Create(attachment).Error) w := httptest.NewRecorder() - url := msgDetailURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID) + url := msgDetailURL(s.testAccount.ID, *s.testConv.DisplayID, s.testMessage.ID) req, _ := http.NewRequest("DELETE", url, nil) s.router.ServeHTTP(w, req) diff --git a/internal/handler/api/v1/notification_handler.go b/internal/handler/api/v1/notification_handler.go index 446a7225..635e2aee 100644 --- a/internal/handler/api/v1/notification_handler.go +++ b/internal/handler/api/v1/notification_handler.go @@ -14,6 +14,7 @@ import ( "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" + "github.com/gochat/gochat/internal/ws" applogger "github.com/gochat/gochat/pkg/logger" "github.com/gochat/gochat/pkg/pagination" "github.com/gochat/gochat/pkg/response" @@ -23,6 +24,7 @@ import ( // Reference: Chatwoot app/controllers/api/v1/accounts/notifications_controller.rb type NotificationHandler struct { notificationService *service.NotificationService + eventPublisher *ws.EventPublisher } // NewNotificationHandler creates a new Notification handler with injected service. @@ -30,6 +32,11 @@ func NewNotificationHandler(notificationService *service.NotificationService) *N return &NotificationHandler{notificationService: notificationService} } +func (h *NotificationHandler) WithEventPublisher(publisher *ws.EventPublisher) *NotificationHandler { + h.eventPublisher = publisher + return h +} + // List returns all notifications for the current user in an account. // GET /api/v1/accounts/:account_id/notifications // Reference: Chatwoot index — NotificationFinder with pagination @@ -113,6 +120,7 @@ func (h *NotificationHandler) Update(c *gin.Context) { handleServiceError(c, svcErr) return } + h.publishNotificationEvent(c.Request.Context(), accountID, userID, ws.EventNotificationUpdated, notification) c.JSON(http.StatusOK, h.serializeNotification(c.Request.Context(), notification)) } @@ -202,6 +210,7 @@ func (h *NotificationHandler) Snooze(c *gin.Context) { handleServiceError(c, svcErr) return } + h.publishNotificationEvent(c.Request.Context(), accountID, userID, ws.EventNotificationUpdated, notification) c.JSON(http.StatusOK, h.serializeNotification(c.Request.Context(), notification)) } @@ -224,6 +233,7 @@ func (h *NotificationHandler) Unread(c *gin.Context) { handleServiceError(c, svcErr) return } + h.publishNotificationEvent(c.Request.Context(), accountID, userID, ws.EventNotificationUpdated, notification) c.JSON(http.StatusOK, h.serializeNotification(c.Request.Context(), notification)) } @@ -240,10 +250,16 @@ func (h *NotificationHandler) Destroy(c *gin.Context) { accountID := getAccountID(c) userID := getUserID(c) + notification, svcErr := h.notificationService.GetNotificationByAccount(c.Request.Context(), notificationID, userID, accountID) + if svcErr != nil { + handleServiceError(c, svcErr) + return + } if svcErr := h.notificationService.DeleteNotificationByAccount(c.Request.Context(), notificationID, userID, accountID); svcErr != nil { handleServiceError(c, svcErr) return } + h.publishNotificationEvent(c.Request.Context(), accountID, userID, ws.EventNotificationDeleted, notification) c.Status(http.StatusOK) } @@ -290,6 +306,27 @@ func notificationIncludes(c *gin.Context, value string) bool { return false } +func (h *NotificationHandler) publishNotificationEvent(ctx context.Context, accountID, userID uint, eventType string, notification *model.Notification) { + if h.eventPublisher == nil || notification == nil || accountID == 0 || userID == 0 { + return + } + unreadCount, err := h.notificationService.GetUnreadCountByAccount(ctx, userID, accountID) + if err != nil { + applogger.L().Warnf("notification event %s unread count: %v", eventType, err) + return + } + total, err := h.notificationService.CountNotificationsByAccount(ctx, userID, accountID) + if err != nil { + applogger.L().Warnf("notification event %s total count: %v", eventType, err) + return + } + h.eventPublisher.PublishEvent(accountID, eventType, gin.H{ + "notification": h.serializeNotification(ctx, notification), + "unread_count": unreadCount, + "count": total, + }) +} + func (h *NotificationHandler) serializeNotification(ctx context.Context, notification *model.Notification) gin.H { var db *gorm.DB if h != nil && h.notificationService != nil { diff --git a/internal/handler/api/v1/notification_handler_test.go b/internal/handler/api/v1/notification_handler_test.go index 43e506c8..0ce98da8 100644 --- a/internal/handler/api/v1/notification_handler_test.go +++ b/internal/handler/api/v1/notification_handler_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "strconv" "strings" + "sync" "testing" "time" @@ -19,6 +20,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/ws" ) func uintPtr(v uint) *uint { return &v } @@ -51,6 +53,38 @@ func setupNotificationHandler(t *testing.T, db *gorm.DB) *NotificationHandler { return NewNotificationHandler(ns) } +type notificationEventHub struct { + mu sync.Mutex + accounts map[uint][]byte +} + +func newNotificationEventHub() *notificationEventHub { + return ¬ificationEventHub{accounts: map[uint][]byte{}} +} + +func (h *notificationEventHub) SendToAccount(accountID uint, data []byte) { + h.mu.Lock() + defer h.mu.Unlock() + h.accounts[accountID] = data +} + +func (h *notificationEventHub) SendToRoom(_ string, _ []byte) {} + +func (h *notificationEventHub) accountData(accountID uint) []byte { + h.mu.Lock() + defer h.mu.Unlock() + return h.accounts[accountID] +} + +func decodeNotificationEvent(t *testing.T, hub *notificationEventHub, accountID uint) ws.WSMessage { + t.Helper() + data := hub.accountData(accountID) + require.NotNil(t, data) + var msg ws.WSMessage + require.NoError(t, json.Unmarshal(data, &msg)) + return msg +} + func setupNotificationRouter(handler *NotificationHandler) *gin.Engine { gin.SetMode(gin.TestMode) router := gin.New() @@ -279,6 +313,30 @@ func TestNotificationGet(t *testing.T) { sqlDB.Close() } +func TestNotificationGetDoesNotPublishRealtimeEvent(t *testing.T) { + db := setupNotificationDB(t) + handler := setupNotificationHandler(t, db) + hub := newNotificationEventHub() + handler.WithEventPublisher(ws.NewEventPublisherLocal(hub, nil)) + router := setupNotificationRouter(handler) + + user := &model.User{Name: "Notification Get User", Email: "notification-get@example.com", Password: "pass", AccountID: 1} + require.NoError(t, db.Create(user).Error) + accountID := uint(1) + notification := &model.Notification{UserID: user.ID, AccountID: &accountID, NotificationType: "conversation_mention", PrimaryActorType: "Conversation", PrimaryActorID: 1} + require.NoError(t, db.Create(notification).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/1/notifications/%d", notification.ID), nil) + req.Header.Set("X-User-ID", strconv.FormatUint(uint64(user.ID), 10)) + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + require.Nil(t, hub.accountData(accountID)) + + sqlDB, _ := db.DB() + sqlDB.Close() +} + func TestNotificationGetDifferentID(t *testing.T) { db := setupNotificationDB(t) handler := setupNotificationHandler(t, db) @@ -757,6 +815,76 @@ func TestNotificationHandler_UnreadWithDB(t *testing.T) { sqlDB.Close() } +func TestNotificationHandler_MutationsPublishChatwootRealtimePayload(t *testing.T) { + db := setupNotificationDB(t) + handler := setupNotificationHandler(t, db) + hub := newNotificationEventHub() + handler.WithEventPublisher(ws.NewEventPublisherLocal(hub, nil)) + router := setupNotificationRouter(handler) + + user := &model.User{Name: "Notification Realtime User", Email: "notification-realtime@example.com", Password: "pass", AccountID: 1} + require.NoError(t, db.Create(user).Error) + accountID := uint(1) + now := time.Now() + readNotification := &model.Notification{UserID: user.ID, AccountID: &accountID, NotificationType: "conversation_assignment", PrimaryActorType: "Conversation", PrimaryActorID: 1} + unreadNotification := &model.Notification{UserID: user.ID, AccountID: &accountID, NotificationType: "conversation_mention", PrimaryActorType: "Conversation", PrimaryActorID: 2, ReadAt: &now} + snoozeNotification := &model.Notification{UserID: user.ID, AccountID: &accountID, NotificationType: "assigned_conversation_new_message", PrimaryActorType: "Conversation", PrimaryActorID: 3} + deleteNotification := &model.Notification{UserID: user.ID, AccountID: &accountID, NotificationType: "message_created", PrimaryActorType: "Conversation", PrimaryActorID: 4} + require.NoError(t, db.Create(readNotification).Error) + require.NoError(t, db.Create(unreadNotification).Error) + require.NoError(t, db.Create(snoozeNotification).Error) + require.NoError(t, db.Create(deleteNotification).Error) + + request := func(method, path, body string) ws.WSMessage { + w := httptest.NewRecorder() + req, _ := http.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-User-ID", strconv.FormatUint(uint64(user.ID), 10)) + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + return decodeNotificationEvent(t, hub, accountID) + } + + msg := request(http.MethodPut, fmt.Sprintf("/api/v1/accounts/1/notifications/%d", readNotification.ID), "") + require.Equal(t, ws.EventNotificationUpdated, msg.Event) + data := msg.Data.(map[string]any) + require.Equal(t, float64(2), data["unread_count"]) + require.Equal(t, float64(4), data["count"]) + notification := data["notification"].(map[string]any) + require.Equal(t, float64(readNotification.ID), notification["id"]) + require.NotNil(t, notification["read_at"]) + + msg = request(http.MethodPost, fmt.Sprintf("/api/v1/accounts/1/notifications/%d/unread", unreadNotification.ID), "") + require.Equal(t, ws.EventNotificationUpdated, msg.Event) + data = msg.Data.(map[string]any) + notification = data["notification"].(map[string]any) + require.Equal(t, float64(unreadNotification.ID), notification["id"]) + require.Nil(t, notification["read_at"]) + require.Equal(t, float64(3), data["unread_count"]) + require.Equal(t, float64(4), data["count"]) + + snoozeUnix := time.Now().Add(time.Hour).Unix() + msg = request(http.MethodPost, fmt.Sprintf("/api/v1/accounts/1/notifications/%d/snooze", snoozeNotification.ID), fmt.Sprintf(`{"snoozed_until":%d}`, snoozeUnix)) + require.Equal(t, ws.EventNotificationUpdated, msg.Event) + data = msg.Data.(map[string]any) + require.Equal(t, float64(3), data["unread_count"]) + require.Equal(t, float64(4), data["count"]) + notification = data["notification"].(map[string]any) + require.Equal(t, float64(snoozeNotification.ID), notification["id"]) + require.NotNil(t, notification["snoozed_until"]) + + msg = request(http.MethodDelete, fmt.Sprintf("/api/v1/accounts/1/notifications/%d", deleteNotification.ID), "") + require.Equal(t, ws.EventNotificationDeleted, msg.Event) + data = msg.Data.(map[string]any) + require.Equal(t, float64(2), data["unread_count"]) + require.Equal(t, float64(3), data["count"]) + notification = data["notification"].(map[string]any) + require.Equal(t, float64(deleteNotification.ID), notification["id"]) + + sqlDB, _ := db.DB() + sqlDB.Close() +} + func TestNotificationHandler_Unread_InvalidID(t *testing.T) { db := setupNotificationDB(t) handler := setupNotificationHandler(t, db) diff --git a/internal/handler/api/v1/notification_setting_handler_test.go b/internal/handler/api/v1/notification_setting_handler_test.go index 78e1b4d8..d4edb336 100644 --- a/internal/handler/api/v1/notification_setting_handler_test.go +++ b/internal/handler/api/v1/notification_setting_handler_test.go @@ -88,8 +88,10 @@ func (s *NotificationSettingHandlerTestSuite) TestShow_Success() { assert.NotContains(s.T(), body, "notification_setting") assert.Equal(s.T(), float64(s.account.ID), body["account_id"]) assert.Equal(s.T(), float64(11), body["user_id"]) - s.Require().Contains(body, "all_email_flags") - s.Require().Contains(body, "selected_email_flags") + assert.Equal(s.T(), stringSliceToInterfaceSlice(model.AllEmailFlagNames()), body["all_email_flags"]) + assert.Equal(s.T(), stringSliceToInterfaceSlice(model.AllPushFlagNames()), body["all_push_flags"]) + assert.Equal(s.T(), stringSliceToInterfaceSlice(model.AllEmailFlagNames()), body["selected_email_flags"]) + assert.Equal(s.T(), stringSliceToInterfaceSlice(model.AllPushFlagNames()), body["selected_push_flags"]) } func (s *NotificationSettingHandlerTestSuite) TestUpdate_BadRequest_InvalidAccountID() { @@ -109,6 +111,10 @@ func (s *NotificationSettingHandlerTestSuite) TestUpdate_BadRequest_InvalidAccou func (s *NotificationSettingHandlerTestSuite) TestUpdate_ReturnsRawChatwootPayload() { r := gin.New() + r.GET("/api/v1/accounts/:account_id/notification_settings", func(c *gin.Context) { + c.Set("user_id", float64(12)) + s.handler.Show(c) + }) r.PATCH("/api/v1/accounts/:account_id/notification_settings", func(c *gin.Context) { c.Set("user_id", float64(12)) s.handler.Update(c) @@ -128,6 +134,30 @@ func (s *NotificationSettingHandlerTestSuite) TestUpdate_ReturnsRawChatwootPaylo assert.NotContains(s.T(), data, "notification_setting") assert.Equal(s.T(), float64(s.account.ID), data["account_id"]) assert.Equal(s.T(), float64(12), data["user_id"]) + assert.Equal(s.T(), stringSliceToInterfaceSlice(model.AllEmailFlagNames()), data["all_email_flags"]) + assert.Equal(s.T(), stringSliceToInterfaceSlice(model.AllPushFlagNames()), data["all_push_flags"]) + assert.Equal(s.T(), []interface{}{"email_conversation_assignment"}, data["selected_email_flags"]) + assert.Equal(s.T(), []interface{}{"push_conversation_mention"}, data["selected_push_flags"]) + + var persisted model.NotificationSetting + s.Require().NoError(s.db.Where("account_id = ? AND user_id = ?", s.account.ID, 12).First(&persisted).Error) + assert.Equal(s.T(), []string{"email_conversation_assignment"}, persisted.SelectedEmailFlagNames()) + assert.Equal(s.T(), []string{"push_conversation_mention"}, persisted.SelectedPushFlagNames()) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/notification_settings", s.account.ID), nil) + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &data)) assert.Equal(s.T(), []interface{}{"email_conversation_assignment"}, data["selected_email_flags"]) assert.Equal(s.T(), []interface{}{"push_conversation_mention"}, data["selected_push_flags"]) } + +func stringSliceToInterfaceSlice(values []string) []interface{} { + items := make([]interface{}, 0, len(values)) + for _, value := range values { + items = append(items, value) + } + return items +} diff --git a/internal/handler/api/v1/notification_subscription_handler_test.go b/internal/handler/api/v1/notification_subscription_handler_test.go index 9c61d949..5bb1383d 100644 --- a/internal/handler/api/v1/notification_subscription_handler_test.go +++ b/internal/handler/api/v1/notification_subscription_handler_test.go @@ -32,10 +32,13 @@ func setupNotificationSubscriptionHandlerTest(t *testing.T, userID uint) (*gin.E router.POST("/api/v1/notification_subscriptions", handler.Create) router.DELETE("/api/v1/notification_subscriptions", handler.Destroy) router.DELETE("/api/v1/notification_subscriptions/:identifier", handler.Destroy) + router.POST("/api/v1/accounts/:account_id/notification_subscriptions/", handler.Create) + router.DELETE("/api/v1/accounts/:account_id/notification_subscriptions/", handler.Destroy) + router.DELETE("/api/v1/accounts/:account_id/notification_subscriptions/:identifier", handler.Destroy) return router, db } -func TestNotificationSubscriptionCreateAcceptsFrontendPayload(t *testing.T) { +func TestNotificationSubscriptionCreateAcceptsPushHelperPayload(t *testing.T) { router, db := setupNotificationSubscriptionHandlerTest(t, 7) body := `{"subscription_type":"browser_push","subscription_attributes":{"endpoint":"https://push.example/sub","p256dh":"key","auth":"secret"}}` @@ -49,14 +52,54 @@ func TestNotificationSubscriptionCreateAcceptsFrontendPayload(t *testing.T) { require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload), w.Body.String()) require.NotContains(t, payload, "success") require.NotContains(t, payload, "data") + require.NotZero(t, payload["id"]) require.Equal(t, "https://push.example/sub", payload["identifier"]) require.Equal(t, "browser_push", payload["subscription_type"]) require.Equal(t, float64(7), payload["user_id"]) + require.NotEmpty(t, payload["created_at"]) + require.NotEmpty(t, payload["updated_at"]) + require.Equal(t, map[string]any{ + "endpoint": "https://push.example/sub", + "p256dh": "key", + "auth": "secret", + }, payload["subscription_attributes"]) var sub model.NotificationSubscription require.NoError(t, db.First(&sub).Error) require.Equal(t, uint(7), sub.UserID) require.Equal(t, "https://push.example/sub", sub.Identifier) + require.Equal(t, model.NotificationSubBrowserPush, sub.SubscriptionType) + require.JSONEq(t, `{"endpoint":"https://push.example/sub","p256dh":"key","auth":"secret"}`, string(sub.SubscriptionAttributes)) +} + +func TestNotificationSubscriptionCreateAcceptsAccountScopedPushHelperPayload(t *testing.T) { + router, db := setupNotificationSubscriptionHandlerTest(t, 9) + body := `{"subscription_type":"browser_push","subscription_attributes":{"endpoint":"https://push.example/account-scoped","p256dh":"account-key","auth":"account-secret"}}` + + req := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/42/notification_subscriptions/", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var payload map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload), w.Body.String()) + require.NotContains(t, payload, "success") + require.NotContains(t, payload, "data") + require.Equal(t, "https://push.example/account-scoped", payload["identifier"]) + require.Equal(t, "browser_push", payload["subscription_type"]) + require.Equal(t, float64(9), payload["user_id"]) + require.Equal(t, map[string]any{ + "endpoint": "https://push.example/account-scoped", + "p256dh": "account-key", + "auth": "account-secret", + }, payload["subscription_attributes"]) + + var sub model.NotificationSubscription + require.NoError(t, db.First(&sub).Error) + require.Equal(t, uint(9), sub.UserID) + require.Equal(t, "https://push.example/account-scoped", sub.Identifier) + require.JSONEq(t, `{"endpoint":"https://push.example/account-scoped","p256dh":"account-key","auth":"account-secret"}`, string(sub.SubscriptionAttributes)) } func TestNotificationSubscriptionCreateAcceptsRailsWrapperAndUpdatesExisting(t *testing.T) { @@ -103,6 +146,27 @@ func TestNotificationSubscriptionDestroyUsesPushTokenAndReturnsEmptyOK(t *testin require.Equal(t, int64(0), count) } +func TestNotificationSubscriptionDestroyAccountScopedUsesPushToken(t *testing.T) { + router, db := setupNotificationSubscriptionHandlerTest(t, 7) + sub := model.NotificationSubscription{ + Identifier: "https://push.example/account-scoped-delete", + UserID: 7, + SubscriptionType: model.NotificationSubBrowserPush, + SubscriptionAttributes: json.RawMessage(`{"endpoint":"https://push.example/account-scoped-delete","p256dh":"key","auth":"secret"}`), + } + require.NoError(t, db.Create(&sub).Error) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/accounts/42/notification_subscriptions/?push_token=https%3A%2F%2Fpush.example%2Faccount-scoped-delete", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Empty(t, w.Body.String()) + var count int64 + require.NoError(t, db.Model(&model.NotificationSubscription{}).Where("id = ?", sub.ID).Count(&count).Error) + require.Equal(t, int64(0), count) +} + func TestNotificationSubscriptionDestroyMissingTokenStillOK(t *testing.T) { router, _ := setupNotificationSubscriptionHandlerTest(t, 7) diff --git a/internal/handler/api/v1/portal_handler_test.go b/internal/handler/api/v1/portal_handler_test.go index 5f7b6295..728e8c4a 100644 --- a/internal/handler/api/v1/portal_handler_test.go +++ b/internal/handler/api/v1/portal_handler_test.go @@ -128,7 +128,7 @@ func (s *PortalHandlerTestSuite) TestGet_BySlugReturnsChatwootMeta() { } func (s *PortalHandlerTestSuite) TestPublicRedirectDefaultLocale() { - portal := &model.Portal{AccountID: s.account.ID, Name: "Public", Slug: "public", Locale: "en", PortalConfiguration: json.RawMessage(`{"default_locale":"fr"}`)} + portal := &model.Portal{AccountID: s.account.ID, Name: "Public", Slug: "public", Locale: "en", PortalConfiguration: json.RawMessage(`{"default_locale":"fr","allowed_locales":["en","fr"]}`)} s.Require().NoError(s.db.Create(portal).Error) r := gin.New() @@ -142,6 +142,21 @@ func (s *PortalHandlerTestSuite) TestPublicRedirectDefaultLocale() { assert.Equal(s.T(), "/hc/public/fr", w.Header().Get("Location")) } +func (s *PortalHandlerTestSuite) TestPublicRedirectDefaultLocaleFallsBackToPortalLocale() { + portal := &model.Portal{AccountID: s.account.ID, Name: "Public Fallback", Slug: "public-fallback", Locale: "es"} + s.Require().NoError(s.db.Create(portal).Error) + + r := gin.New() + r.GET("/hc/:slug", s.handler.PublicRedirectDefaultLocale) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/hc/public-fallback", nil) + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusFound, w.Code) + assert.Equal(s.T(), "/hc/public-fallback/es", w.Header().Get("Location")) +} + func (s *PortalHandlerTestSuite) TestPublicGet_ReturnsChatwootHCPortalPayload() { portal := &model.Portal{AccountID: s.account.ID, Name: "Help", Slug: "help", HeaderText: "How can we help?", HomepageLink: "https://example.com", PageTitle: "Help Center", LogoURL: "https://cdn.example/logo.png", Locale: "en", PortalConfiguration: json.RawMessage(`{"default_locale":"en"}`)} s.Require().NoError(s.db.Create(portal).Error) @@ -212,6 +227,7 @@ func (s *PortalHandlerTestSuite) TestPublicSitemap_ReturnsPublishedArticleURLs() assert.Contains(s.T(), body, `https://help.example.com/hc/sitemap/articles/published`) assert.Contains(s.T(), body, ``) assert.NotContains(s.T(), body, "draft") + assert.NotContains(s.T(), body, "") } func (s *PortalHandlerTestSuite) TestUpdate_Success() { diff --git a/internal/handler/api/v1/profile_handler_test.go b/internal/handler/api/v1/profile_handler_test.go index 5a40a8fd..f04b2178 100644 --- a/internal/handler/api/v1/profile_handler_test.go +++ b/internal/handler/api/v1/profile_handler_test.go @@ -126,6 +126,7 @@ func (s *ProfileHandlerTestSuite) buildRouter() *gin.Engine { profile.GET("", s.handler.Get) profile.PUT("", s.handler.Update) profile.PUT("/avatar", s.handler.UpdateAvatar) + profile.DELETE("/avatar", s.handler.DeleteAvatar) profile.POST("/availability", s.handler.SetAvailability) profile.POST("/auto_offline", s.handler.SetAutoOffline) profile.PUT("/set_active_account", s.handler.SetActiveAccount) @@ -200,6 +201,44 @@ func (s *ProfileHandlerTestSuite) firstAccountFromProfile(payload map[string]int return account } +func (s *ProfileHandlerTestSuite) assertChatwootProfileUserFixture(payload map[string]interface{}, expectedAvailability string, expectedAutoOffline bool) map[string]interface{} { + s.T().Helper() + + assert.Equal(s.T(), float64(s.userID), payload["id"]) + assert.Equal(s.T(), "ProfileUser", payload["name"]) + assert.Equal(s.T(), "profile@example.com", payload["email"]) + assert.Equal(s.T(), "", payload["uid"]) + assert.Equal(s.T(), "Profile Display", payload["available_name"]) + assert.Equal(s.T(), "Profile Display", payload["display_name"]) + assert.Equal(s.T(), "", payload["avatar_url"]) + assert.Equal(s.T(), "User", payload["type"]) + assert.Equal(s.T(), "email", payload["provider"]) + assert.Equal(s.T(), "pubsub-profile-user", payload["pubsub_token"]) + assert.Equal(s.T(), "Regards", payload["message_signature"]) + assert.Equal(s.T(), "profile-token-1", payload["access_token"]) + assert.Equal(s.T(), "administrator", payload["role"]) + assert.Equal(s.T(), map[string]interface{}{}, payload["custom_attributes"]) + assert.Equal(s.T(), map[string]interface{}{}, payload["ui_settings"]) + assert.Equal(s.T(), false, payload["confirmed"]) + assert.Equal(s.T(), float64(s.accountID), payload["account_id"]) + assert.Nil(s.T(), payload["inviter_id"]) + assert.NotContains(s.T(), payload, "hmac_identifier") + + account := s.firstAccountFromProfile(payload) + assert.Equal(s.T(), float64(s.accountID), account["id"]) + assert.Equal(s.T(), "TestAccount", account["name"]) + assert.Equal(s.T(), "active", account["status"]) + assert.Equal(s.T(), "profile", account["onboarding_step"]) + assert.Equal(s.T(), "administrator", account["role"]) + assert.Equal(s.T(), expectedAvailability, account["availability"]) + assert.Equal(s.T(), expectedAvailability, account["availability_status"]) + assert.Equal(s.T(), expectedAutoOffline, account["auto_offline"]) + assert.Equal(s.T(), []interface{}{"administrator"}, account["permissions"]) + assert.Nil(s.T(), account["custom_role_id"]) + assert.Nil(s.T(), account["custom_role"]) + return account +} + // ===================== Get Profile ===================== func (s *ProfileHandlerTestSuite) TestGet_Success() { @@ -210,24 +249,7 @@ func (s *ProfileHandlerTestSuite) TestGet_Success() { assert.Equal(s.T(), http.StatusOK, w.Code) dataMap := s.decodeProfileBody(w) - assert.Equal(s.T(), "ProfileUser", dataMap["name"]) - assert.Equal(s.T(), "profile@example.com", dataMap["email"]) - assert.Equal(s.T(), "profile-token-1", dataMap["access_token"]) - assert.Equal(s.T(), "Profile Display", dataMap["available_name"]) - assert.Equal(s.T(), "Regards", dataMap["message_signature"]) - assert.Equal(s.T(), "pubsub-profile-user", dataMap["pubsub_token"]) - assert.NotContains(s.T(), dataMap, "hmac_identifier") - assert.Equal(s.T(), "administrator", dataMap["role"]) - accounts, ok := dataMap["accounts"].([]interface{}) - assert.True(s.T(), ok) - if assert.Len(s.T(), accounts, 1) { - account := accounts[0].(map[string]interface{}) - assert.Equal(s.T(), "TestAccount", account["name"]) - assert.Equal(s.T(), "offline", account["availability"]) - assert.Equal(s.T(), "offline", account["availability_status"]) - assert.Equal(s.T(), true, account["auto_offline"]) - assert.Equal(s.T(), []interface{}{"administrator"}, account["permissions"]) - } + s.assertChatwootProfileUserFixture(dataMap, "offline", true) } func (s *ProfileHandlerTestSuite) TestGet_CustomRolePermissions() { @@ -694,6 +716,22 @@ func (s *ProfileHandlerTestSuite) TestUpdateAvatar_NilService() { // Nil service causes panic — validates catastrophic failure path } +func (s *ProfileHandlerTestSuite) TestDeleteAvatar_ReturnsChatwootUserSerializer() { + s.Require().NoError(s.db.Model(&model.User{}).Where("id = ?", s.userID).Update("avatar_url", "https://cdn.example.com/current-avatar.png").Error) + + req, _ := http.NewRequest(http.MethodDelete, "/api/v1/profile/avatar", nil) + w := httptest.NewRecorder() + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + dataMap := s.decodeProfileBody(w) + assert.Equal(s.T(), "", dataMap["avatar_url"]) + + var user model.User + s.Require().NoError(s.db.First(&user, s.userID).Error) + assert.Equal(s.T(), "", user.AvatarURL) +} + // ===================== Chatwoot Profile Serializer Fixtures ===================== func (s *ProfileHandlerTestSuite) TestSetAvailability_ReturnsChatwootUserSerializer() { @@ -712,11 +750,14 @@ func (s *ProfileHandlerTestSuite) TestSetAvailability_ReturnsChatwootUserSeriali assert.Equal(s.T(), http.StatusOK, w.Code) payload := s.decodeProfileBody(w) - assert.Equal(s.T(), "ProfileUser", payload["name"]) - assert.Equal(s.T(), "administrator", payload["role"]) + s.assertChatwootProfileUserFixture(payload, "online", true) account := s.firstAccountFromProfile(payload) assert.Equal(s.T(), "online", account["availability"]) assert.Equal(s.T(), "online", account["availability_status"]) + + var accountUser model.AccountUser + s.Require().NoError(s.db.Where("account_id = ? AND user_id = ?", s.accountID, s.userID).First(&accountUser).Error) + assert.Equal(s.T(), "online", accountUser.Availability) } func (s *ProfileHandlerTestSuite) TestSetAutoOffline_ReturnsChatwootUserSerializer() { @@ -735,8 +776,38 @@ func (s *ProfileHandlerTestSuite) TestSetAutoOffline_ReturnsChatwootUserSerializ assert.Equal(s.T(), http.StatusOK, w.Code) payload := s.decodeProfileBody(w) + s.assertChatwootProfileUserFixture(payload, "offline", false) account := s.firstAccountFromProfile(payload) assert.Equal(s.T(), false, account["auto_offline"]) + + var accountUser model.AccountUser + s.Require().NoError(s.db.Where("account_id = ? AND user_id = ?", s.accountID, s.userID).First(&accountUser).Error) + assert.False(s.T(), accountUser.AutoOffline) +} + +func (s *ProfileHandlerTestSuite) TestSetActiveAccount_UpdatesMembershipActiveAt() { + var before model.AccountUser + s.Require().NoError(s.db.Where("account_id = ? AND user_id = ?", s.accountID, s.userID).First(&before).Error) + s.Require().Nil(before.ActiveAt) + + body := map[string]interface{}{ + "profile": map[string]interface{}{ + "account_id": s.accountID, + }, + } + b, _ := json.Marshal(body) + req, _ := http.NewRequest("PUT", "/api/v1/profile/set_active_account", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusNoContent, w.Code) + assert.Empty(s.T(), w.Body.String()) + + var after model.AccountUser + s.Require().NoError(s.db.Where("account_id = ? AND user_id = ?", s.accountID, s.userID).First(&after).Error) + s.Require().NotNil(after.ActiveAt) + assert.WithinDuration(s.T(), time.Now(), *after.ActiveAt, 5*time.Second) } func (s *ProfileHandlerTestSuite) TestResetAccessToken_RegeneratesTokenInChatwootUserSerializer() { diff --git a/internal/handler/api/v1/shopify_integration_handler_test.go b/internal/handler/api/v1/shopify_integration_handler_test.go index acfa42ca..4afdc265 100644 --- a/internal/handler/api/v1/shopify_integration_handler_test.go +++ b/internal/handler/api/v1/shopify_integration_handler_test.go @@ -5,10 +5,20 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "net/url" + "strings" "testing" "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" + + "github.com/gochat/gochat/internal/model" + "github.com/gochat/gochat/internal/repository" + "github.com/gochat/gochat/internal/service" ) func setupShopifyIntegrationRouter() *gin.Engine { @@ -89,6 +99,40 @@ func TestShopifyIntegration_Auth_MissingShopDomain(t *testing.T) { assert.Equal(t, "Shop domain is required", resp["error"]) } +func TestShopifyIntegration_Auth_ReturnsChatwootRedirectPayload(t *testing.T) { + t.Setenv("SHOPIFY_CLIENT_ID", "shopify-client") + t.Setenv("SHOPIFY_CLIENT_SECRET", "shopify-secret") + t.Setenv("FRONTEND_URL", "https://app.example.test/") + + db, err := gorm.Open(sqlite.Open("file:shopify_auth_success?mode=memory&cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + t.Cleanup(func() { sqlDB, _ := db.DB(); _ = sqlDB.Close() }) + require.NoError(t, db.AutoMigrate(&model.IntegrationHook{})) + svc := service.NewShopifyIntegrationService(repository.NewIntegrationHookRepo(db)) + r := gin.New() + r.RedirectTrailingSlash = false + RegisterShopifyIntegrationRoutes(r.Group("/api/v1/accounts/:account_id/integrations"), NewShopifyIntegrationHandler(svc)) + + w := httptest.NewRecorder() + body := bytes.NewReader([]byte(`{"shop_domain":"store.myshopify.com"}`)) + req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/7/integrations/shopify/auth", body) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]string + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + redirectURL := resp["redirect_url"] + assert.True(t, strings.HasPrefix(redirectURL, "https://store.myshopify.com/admin/oauth/authorize?")) + parsed, err := url.Parse(redirectURL) + require.NoError(t, err) + query := parsed.Query() + assert.Equal(t, "shopify-client", query.Get("client_id")) + assert.Equal(t, "read_customers,read_orders,read_fulfillments", query.Get("scope")) + assert.Equal(t, "https://app.example.test/shopify/callback", query.Get("redirect_uri")) + assert.NotEmpty(t, query.Get("state")) +} + func TestShopifyIntegration_GetOrders_BadAccountID(t *testing.T) { r := setupShopifyIntegrationRouter() diff --git a/internal/handler/api/v1/sla_policy_handler_test.go b/internal/handler/api/v1/sla_policy_handler_test.go index b3cb4954..f9bfa258 100644 --- a/internal/handler/api/v1/sla_policy_handler_test.go +++ b/internal/handler/api/v1/sla_policy_handler_test.go @@ -548,6 +548,67 @@ func TestSlaPolicyHandler_ListInboxes_Success(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) } +func TestSlaPolicyHandler_InboxAssociationChatwootPayloadAndSideEffects(t *testing.T) { + handler, db := setupSlaPolicyHandlerTest(t) + router := setupSlaPolicyTestRouter(handler) + aid := slaHandlerAccountID(db) + accountUID := slaHandlerAccountIDUint(db) + + svc := service.NewSlaPolicyService( + repository.NewSlaPolicyRepo(db), + repository.NewAppliedSlaRepo(db), + repository.NewSlaEventRepo(db), + repository.NewSlaPolicyInboxRepo(db), + ) + policy, err := svc.Create(nil, accountUID, &service.CreateSlaPolicyRequest{ + Name: "Inbox Linked SLA", FirstResponseTimeThreshold: 10, NextResponseTimeThreshold: 20, ResolutionTimeThreshold: 100, + }) + require.NoError(t, err) + inbox := &model.Inbox{Name: "Priority Inbox", AccountID: accountUID, ChannelType: "web_widget"} + require.NoError(t, db.Create(inbox).Error) + + addBody, _ := json.Marshal(map[string]any{"inbox_id": inbox.ID}) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/"+aid+"/sla_policies/"+strconv.FormatUint(uint64(policy.ID), 10)+"/inboxes", bytes.NewBuffer(addBody)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) + + var addResp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &addResp)) + payload := addResp["data"].(map[string]any) + assert.Equal(t, float64(policy.ID), payload["sla_policy_id"]) + assert.Equal(t, float64(inbox.ID), payload["inbox_id"]) + assert.Equal(t, float64(accountUID), payload["account_id"]) + assert.Equal(t, true, addResp["success"]) + + var associationCount int64 + require.NoError(t, db.Model(&model.SlaPolicyInbox{}).Where("sla_policy_id = ? AND inbox_id = ? AND account_id = ?", policy.ID, inbox.ID, accountUID).Count(&associationCount).Error) + assert.Equal(t, int64(1), associationCount) + + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, "/api/v1/accounts/"+aid+"/sla_policies/"+strconv.FormatUint(uint64(policy.ID), 10)+"/inboxes", nil) + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + var listResp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &listResp)) + listPayload := listResp["data"].([]any) + require.Len(t, listPayload, 1) + listed := listPayload[0].(map[string]any) + assert.Equal(t, float64(policy.ID), listed["sla_policy_id"]) + assert.Equal(t, float64(inbox.ID), listed["inbox_id"]) + assert.Equal(t, float64(accountUID), listed["account_id"]) + + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodDelete, "/api/v1/accounts/"+aid+"/sla_policies/"+strconv.FormatUint(uint64(policy.ID), 10)+"/inboxes/"+strconv.FormatUint(uint64(inbox.ID), 10), nil) + router.ServeHTTP(w, req) + require.Equal(t, http.StatusNoContent, w.Code, w.Body.String()) + + require.NoError(t, db.Model(&model.SlaPolicyInbox{}).Where("sla_policy_id = ? AND inbox_id = ? AND account_id = ?", policy.ID, inbox.ID, accountUID).Count(&associationCount).Error) + assert.Equal(t, int64(0), associationCount) +} + // ========== Applied SLA reports ========== func TestSlaPolicyHandler_ListAppliedSlas_ChatwootPayloadAndFilters(t *testing.T) { diff --git a/internal/handler/api/v1/slack_integration_handler_test.go b/internal/handler/api/v1/slack_integration_handler_test.go index 1c0828f0..29917ace 100644 --- a/internal/handler/api/v1/slack_integration_handler_test.go +++ b/internal/handler/api/v1/slack_integration_handler_test.go @@ -3,8 +3,10 @@ package v1 import ( "bytes" "encoding/json" + "io" "net/http" "net/http/httptest" + "strings" "testing" "github.com/gin-gonic/gin" @@ -19,6 +21,16 @@ import ( "github.com/gochat/gochat/internal/service" ) +type slackHandlerRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f slackHandlerRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func slackHandlerJSONResponse(body string) *http.Response { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))} +} + func setupSlackIntegrationRouter() *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() @@ -189,6 +201,35 @@ func TestSlackIntegration_Delete_NoTrailingSlash_ReturnsEmptyOK(t *testing.T) { assert.Empty(t, w.Body.String()) } +func TestSlackIntegration_ListAllChannels_NoTrailingSlash_ReturnsChannelArray(t *testing.T) { + db, accountID := setupSlackIntegrationHandlerDB(t) + require.NoError(t, db.Create(&model.IntegrationHook{AccountID: accountID, AppID: "slack", HookType: model.HookTypeSlack, AccessToken: "xoxb-handler-token"}).Error) + svc := service.NewSlackIntegrationService(repository.NewIntegrationHookRepo(db), service.WithSlackHTTPClient("https://slack.test/api", &http.Client{Transport: slackHandlerRoundTripFunc(func(req *http.Request) (*http.Response, error) { + require.Equal(t, "/api/conversations.list", req.URL.Path) + require.Equal(t, "Bearer xoxb-handler-token", req.Header.Get("Authorization")) + if req.URL.Query().Get("types") == "private_channel" { + return slackHandlerJSONResponse(`{"ok":true,"channels":[{"id":"G1","name":"private-room","is_private":true}],"response_metadata":{"next_cursor":""}}`), nil + } + return slackHandlerJSONResponse(`{"ok":true,"channels":[{"id":"C1","name":"support","is_private":false}],"response_metadata":{"next_cursor":""}}`), nil + })})) + r := setupSlackIntegrationRouterWithService(svc) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/accounts/1/integrations/slack/list_all_channels", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp []map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Len(t, resp, 2) + assert.Equal(t, "G1", resp[0]["id"]) + assert.Equal(t, "private-room", resp[0]["name"]) + assert.Equal(t, true, resp[0]["is_private"]) + assert.Equal(t, "C1", resp[1]["id"]) + assert.Equal(t, "support", resp[1]["name"]) + assert.Equal(t, false, resp[1]["is_private"]) +} + func TestSlackIntegration_Update_PutNoTrailingSlash_BadAccountID(t *testing.T) { r := setupSlackIntegrationRouter() diff --git a/internal/handler/api/v1/team_handler_test.go b/internal/handler/api/v1/team_handler_test.go index 04b97215..d10749d5 100644 --- a/internal/handler/api/v1/team_handler_test.go +++ b/internal/handler/api/v1/team_handler_test.go @@ -32,7 +32,7 @@ func (s *TeamHandlerTestSuite) SetupSuite() { Logger: logger.Default.LogMode(logger.Silent), }) s.Require().NoError(err) - s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.Team{}, &model.TeamMember{})) + s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{}, &model.Team{}, &model.TeamMember{})) s.db = db teamRepo := repository.NewTeamRepo(db) @@ -202,6 +202,51 @@ func (s *TeamHandlerTestSuite) TestDelete_Success() { assert.Equal(s.T(), http.StatusOK, w.Code) } +func (s *TeamHandlerTestSuite) TestTeamMembers_ChatwootPayloadAndDiffUpdate() { + team := &model.Team{AccountID: s.account.ID, Name: "members-test-team"} + s.Require().NoError(s.db.Create(team).Error) + agentOne := &model.User{AccountID: s.account.ID, Name: "Agent One", Email: "agent-one@example.test", Role: "agent"} + s.Require().NoError(s.db.Create(agentOne).Error) + s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: s.account.ID, UserID: agentOne.ID, Role: "agent"}).Error) + agentTwo := &model.User{AccountID: s.account.ID, Name: "Agent Two", Email: "agent-two@example.test", Role: "agent"} + s.Require().NoError(s.db.Create(agentTwo).Error) + s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: s.account.ID, UserID: agentTwo.ID, Role: "agent"}).Error) + s.Require().NoError(s.db.Create(&model.TeamMember{TeamID: team.ID, UserID: agentOne.ID, AvailabilityStatus: "online"}).Error) + + r := gin.New() + r.GET("/api/v1/accounts/:account_id/teams/:team_id/team_members", func(c *gin.Context) { + s.authMiddleware(c) + s.handler.ListMembers(c) + }) + r.PATCH("/api/v1/accounts/:account_id/teams/:team_id/team_members", func(c *gin.Context) { + s.authMiddleware(c) + s.handler.UpdateMembers(c) + }) + + listBefore := httptest.NewRecorder() + req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/teams/%d/team_members", s.account.ID, team.ID), nil) + r.ServeHTTP(listBefore, req) + + assert.Equal(s.T(), http.StatusOK, listBefore.Code) + var before []map[string]any + s.Require().NoError(json.Unmarshal(listBefore.Body.Bytes(), &before)) + s.Require().Len(before, 1) + assert.Equal(s.T(), "Agent One", before[0]["name"]) + assert.Equal(s.T(), "online", before[0]["availability_status"]) + + body := fmt.Sprintf(`{"user_ids":[%d]}`, agentTwo.ID) + update := httptest.NewRecorder() + req, _ = http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/teams/%d/team_members", s.account.ID, team.ID), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(update, req) + + assert.Equal(s.T(), http.StatusOK, update.Code) + var after []map[string]any + s.Require().NoError(json.Unmarshal(update.Body.Bytes(), &after)) + s.Require().Len(after, 1) + assert.Equal(s.T(), "Agent Two", after[0]["name"]) +} + func (s *TeamHandlerTestSuite) TestGet_BadRequest_InvalidID() { r := gin.New() r.GET("/api/v1/accounts/:account_id/teams/:id", func(c *gin.Context) { diff --git a/internal/handler/api/v1/tiktok_channel_handler.go b/internal/handler/api/v1/tiktok_channel_handler.go index fefc994a..01787a94 100644 --- a/internal/handler/api/v1/tiktok_channel_handler.go +++ b/internal/handler/api/v1/tiktok_channel_handler.go @@ -127,8 +127,12 @@ func (h *TikTokChannelHandler) CreateTikTokChannel(c *gin.Context) { } createdInbox, err := h.inboxSvc.Create(ctx, uint(accountID), service.CreateInboxRequest{ - Name: inboxName, - ChannelType: "tiktok", + Name: inboxName, + ChannelType: "tiktok", + Channel: map[string]any{ + "tiktok_business_id": req.TikTokBusinessID, + "access_token": req.AccessToken, + }, EnableAutoAssignment: req.EnableAutoAssignment, }) if err != nil { @@ -156,8 +160,9 @@ func (h *TikTokChannelHandler) CreateTikTokChannel(c *gin.Context) { "tiktok_business_id": req.TikTokBusinessID, "access_token": req.AccessToken, } - updatedConfig, onCreateErr := h.ttProvider.OnCreate(ctx, createdInbox, ttConfig) - if onCreateErr != nil { + if h.ttProvider == nil { + applogger.L().Warnf("TikTok OnCreate webhook setup skipped: provider is not configured") + } else if updatedConfig, onCreateErr := h.ttProvider.OnCreate(ctx, createdInbox, ttConfig); onCreateErr != nil { applogger.L().Warnf("TikTok OnCreate webhook setup failed: %v", onCreateErr) // Non-critical: channel + inbox created, webhook can be set up later } else { diff --git a/internal/handler/api/v1/tiktok_channel_handler_test.go b/internal/handler/api/v1/tiktok_channel_handler_test.go index a8315c72..bf8ffd9e 100644 --- a/internal/handler/api/v1/tiktok_channel_handler_test.go +++ b/internal/handler/api/v1/tiktok_channel_handler_test.go @@ -14,8 +14,8 @@ import ( "gorm.io/driver/sqlite" "gorm.io/gorm" - channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/channel/whatsapp" "github.com/gochat/gochat/internal/repository" @@ -126,6 +126,58 @@ func TestTikTokChannel_Create_InvalidRequestBody(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) } +func TestTikTokChannel_Create_ChatwootSetupPayloadAndConfig(t *testing.T) { + handler, db := setupTikTokHandlerTest(t) + router := setupTikTokTestRouter(handler) + accountID := tikTokAccountID(db) + + body := CreateTikTokChannelRequest{ + Name: "TikTok Business", + TikTokBusinessID: "tt_biz_123", + AccessToken: "tt-access-token", + InboxName: "TikTok Inbox", + EnableAutoAssignment: true, + } + b, _ := json.Marshal(body) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/"+accountID+"/tiktok_channel", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.NotContains(t, resp, "success") + assert.NotContains(t, resp, "data") + + channelPayload := resp["channel"].(map[string]any) + inboxPayload := resp["inbox"].(map[string]any) + assert.Equal(t, "tt_biz_123", channelPayload["tiktok_business_id"]) + assert.Equal(t, "TikTok Inbox", inboxPayload["name"]) + assert.Equal(t, "tiktok", inboxPayload["channel_type"]) + assert.Equal(t, true, inboxPayload["enabled"]) + assert.Equal(t, true, inboxPayload["enable_auto_assignment"]) + assert.Equal(t, channelPayload["inbox_id"], inboxPayload["id"]) + + var inbox model.Inbox + require.NoError(t, db.First(&inbox, uint(inboxPayload["id"].(float64))).Error) + assert.Equal(t, "TikTok Inbox", inbox.Name) + assert.Equal(t, "tiktok", inbox.ChannelType) + assert.True(t, inbox.EnableAutoAssignment) + var config map[string]any + require.NoError(t, json.Unmarshal([]byte(inbox.ChannelConfig), &config)) + assert.Equal(t, "tt_biz_123", config["tiktok_business_id"]) + assert.Equal(t, "tt-access-token", config["access_token"]) + + var channel channelmodel.ChannelTikTok + require.NoError(t, db.First(&channel, uint(channelPayload["id"].(float64))).Error) + assert.Equal(t, inbox.ID, channel.InboxID) + assert.Equal(t, inbox.ID, uint(channelPayload["inbox_id"].(float64))) + assert.Equal(t, "tt_biz_123", channel.TikTokBusinessID) + assert.Equal(t, "tt-access-token", channel.AccessToken) +} + // ── Get ────────────────────────────────────────────────────────── func TestTikTokChannel_Get_Success(t *testing.T) { @@ -368,4 +420,4 @@ func TestTikTokChannel_List_Empty(t *testing.T) { channels, ok := resp["channels"].([]interface{}) require.True(t, ok) assert.Len(t, channels, 0) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/twilio_channel_handler_test.go b/internal/handler/api/v1/twilio_channel_handler_test.go index 64c24c98..0d1970f4 100644 --- a/internal/handler/api/v1/twilio_channel_handler_test.go +++ b/internal/handler/api/v1/twilio_channel_handler_test.go @@ -131,6 +131,27 @@ func TestTwilioChannel_Create_Success(t *testing.T) { assert.Equal(t, "sms", resp["medium"]) require.NotContains(t, resp, "channel") require.NotContains(t, resp, "inbox") + + var inbox model.Inbox + require.NoError(t, db.First(&inbox, uint(resp["id"].(float64))).Error) + assert.Equal(t, "Support SMS", inbox.Name) + assert.Equal(t, "twilio_sms", inbox.ChannelType) + assert.Equal(t, uint(resp["channel_id"].(float64)), inbox.ChannelID) + var config map[string]any + require.NoError(t, json.Unmarshal([]byte(inbox.ChannelConfig), &config)) + assert.Equal(t, "ACtest123", config["account_sid"]) + assert.Equal(t, "SKtest123", config["api_key_sid"]) + assert.Equal(t, "authtoken_secret", config["auth_token"]) + assert.Equal(t, "+15551234567", config["phone_number"]) + assert.Equal(t, "MG123", config["messaging_service_sid"]) + assert.Equal(t, "sms", config["medium"]) + + var channel channelmodel.ChannelTwilioSMS + require.NoError(t, db.First(&channel, inbox.ChannelID).Error) + assert.Equal(t, inbox.ID, channel.InboxID) + assert.Equal(t, "ACtest123", channel.AccountSID) + assert.Equal(t, "+15551234567", channel.PhoneNumber) + assert.Equal(t, "MG123", channel.MessagingServiceSID) } func TestTwilioChannel_Create_InvalidAccountID(t *testing.T) { diff --git a/internal/handler/api/v1/upload_handler_test.go b/internal/handler/api/v1/upload_handler_test.go index 16dae3ed..6697c86c 100644 --- a/internal/handler/api/v1/upload_handler_test.go +++ b/internal/handler/api/v1/upload_handler_test.go @@ -209,12 +209,27 @@ func TestUploadHandler_WidgetActiveStorageDirectUploadFlow(t *testing.T) { assert.Equal(t, "visitor.png", createResp["filename"]) directUpload := createResp["direct_upload"].(map[string]any) assert.Equal(t, "/api/v1/widget/direct_uploads/"+signedID, directUpload["url"]) + assert.Equal(t, map[string]any{"Content-Type": "image/png"}, directUpload["headers"]) + assert.Equal(t, "gochat_local", createResp["service_name"]) + assert.Equal(t, float64(11), createResp["byte_size"]) + assert.Equal(t, "checksum-token", createResp["checksum"]) + assert.Equal(t, true, createResp["metadata"].(map[string]any)["identified"]) + assert.NotEmpty(t, createResp["key"]) wPut := httptest.NewRecorder() reqPut, _ := http.NewRequest("PUT", directUpload["url"].(string), bytes.NewReader([]byte("hello image"))) reqPut.Header.Set("Content-Type", "image/png") router.ServeHTTP(wPut, reqPut) require.Equal(t, http.StatusOK, wPut.Code) + var completeResp map[string]any + require.NoError(t, json.Unmarshal(wPut.Body.Bytes(), &completeResp)) + require.Equal(t, true, completeResp["success"]) + completeData := completeResp["data"].(map[string]any) + assert.Equal(t, signedID, completeData["upload_uuid"]) + assert.Equal(t, "visitor.png", completeData["original_name"]) + assert.Equal(t, "image", completeData["file_type"]) + assert.Equal(t, "image/png", completeData["mime_type"]) + assert.Equal(t, float64(11), completeData["file_size"]) var upload model.DirectUpload require.NoError(t, db.Where("upload_uuid = ?", signedID).First(&upload).Error) @@ -286,12 +301,26 @@ func TestUploadHandler_ConversationActiveStorageDirectUploadFlow(t *testing.T) { assert.Equal(t, "agent-note.pdf", createResp["filename"]) directUpload := createResp["direct_upload"].(map[string]any) assert.Equal(t, createPath+"/"+signedID, directUpload["url"]) + assert.Equal(t, map[string]any{"Content-Type": "application/pdf"}, directUpload["headers"]) + assert.Equal(t, "gochat_local", createResp["service_name"]) + assert.Equal(t, float64(12), createResp["byte_size"]) + assert.Equal(t, "pdf-checksum", createResp["checksum"]) + assert.NotEmpty(t, createResp["key"]) wPut := httptest.NewRecorder() reqPut, _ := http.NewRequest("PUT", directUpload["url"].(string), bytes.NewReader([]byte("hello report"))) reqPut.Header.Set("Content-Type", "application/pdf") router.ServeHTTP(wPut, reqPut) require.Equal(t, http.StatusOK, wPut.Code) + var completeResp map[string]any + require.NoError(t, json.Unmarshal(wPut.Body.Bytes(), &completeResp)) + require.Equal(t, true, completeResp["success"]) + completeData := completeResp["data"].(map[string]any) + assert.Equal(t, signedID, completeData["upload_uuid"]) + assert.Equal(t, "agent-note.pdf", completeData["original_name"]) + assert.Equal(t, "file", completeData["file_type"]) + assert.Equal(t, "application/pdf", completeData["mime_type"]) + assert.Equal(t, float64(12), completeData["file_size"]) var upload model.DirectUpload require.NoError(t, db.Where("upload_uuid = ?", signedID).First(&upload).Error) @@ -302,6 +331,55 @@ func TestUploadHandler_ConversationActiveStorageDirectUploadFlow(t *testing.T) { assert.Equal(t, []byte("hello report"), storedBytes) } +func TestUploadHandler_ConversationActiveStorageDirectUploadRejectsUnsupportedMIME(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &model.Account{}, + &model.Inbox{}, + &model.Contact{}, + &model.Conversation{}, + &model.DirectUpload{}, + )) + + account := &model.Account{Name: "Conversation Upload Validation Org", Status: "active"} + require.NoError(t, db.Create(account).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "Conversation Upload Validation Inbox", ChannelType: "web_widget", Enabled: true} + require.NoError(t, db.Create(inbox).Error) + contact := &model.Contact{AccountID: account.ID, Name: "Composer Validation"} + require.NoError(t, db.Create(contact).Error) + displayID := uint(45) + conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"} + require.NoError(t, db.Create(conversation).Error) + + uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{Storage: config.StorageConfig{LocalPath: t.TempDir(), MaxFileSize: 50 << 20}}).WithConversationRepo(repository.NewConversationRepo(db)) + router := setupUploadHandlerRouter(NewUploadHandler(uploadSvc)) + + metadataBody, err := json.Marshal(map[string]any{ + "blob": map[string]any{ + "filename": "malware.exe", + "byte_size": 12, + "checksum": "bad-checksum", + "content_type": "application/x-msdownload", + }, + }) + require.NoError(t, err) + + w := httptest.NewRecorder() + path := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/conversations/45/direct_uploads" + req, _ := http.NewRequest("POST", path, bytes.NewReader(metadataBody)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + var payload map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload)) + assert.Equal(t, false, payload["success"]) + assert.Equal(t, "VALIDATION_ERROR", payload["error"].(map[string]any)["code"]) + assert.Contains(t, payload["error"].(map[string]any)["message"], "unsupported file type") +} + func TestUploadHandler_AccountDirectUpload_NoFile(t *testing.T) { // Create handler with nil service — we only test validation before service call h := &UploadHandler{svc: nil} diff --git a/internal/handler/webhook/incoming_persister.go b/internal/handler/webhook/incoming_persister.go index ce103917..fb2c82e6 100644 --- a/internal/handler/webhook/incoming_persister.go +++ b/internal/handler/webhook/incoming_persister.go @@ -256,7 +256,15 @@ func (p *IncomingPersister) performContactMessagesStatusUpdate(ctx context.Conte return err } for i := range messages { - if !validProviderMessageStatusTransition(model.MessageStatus(messages[i].Status), status) { + currentStatus := model.MessageStatus(messages[i].Status) + if currentStatus == status { + if err := p.upsertDeliveryStatus(ctx, tx, &messages[i], contactInbox.ContactID, status, occurredAt); err != nil { + return err + } + updatedMessages = append(updatedMessages, messages[i]) + continue + } + if !validProviderMessageStatusTransition(currentStatus, status) { continue } if err := tx.Model(&messages[i]).Updates(map[string]any{ diff --git a/internal/handler/webhook/incoming_persister_jobs.go b/internal/handler/webhook/incoming_persister_jobs.go index 03e6369a..ff13a53d 100644 --- a/internal/handler/webhook/incoming_persister_jobs.go +++ b/internal/handler/webhook/incoming_persister_jobs.go @@ -195,7 +195,7 @@ func incomingMessageQueue(channelType channel.ChannelType) string { func validProviderMessageStatusTransition(current, next model.MessageStatus) bool { if !validProviderMessageStatus(next) || current == next { - return validProviderMessageStatus(next) + return false } if next == model.MessageStatusFailed || current == model.MessageStatusFailed { return true diff --git a/internal/handler/webhook/twilio_webhook.go b/internal/handler/webhook/twilio_webhook.go index a7d5ff9e..681a5759 100644 --- a/internal/handler/webhook/twilio_webhook.go +++ b/internal/handler/webhook/twilio_webhook.go @@ -257,6 +257,7 @@ func (h *TwilioWebhookHandler) lookupInboxByPhoneNumber(phoneNumber string) (*mo if h.db == nil { return nil, fmt.Errorf("twilio webhook database is not configured") } + phoneNumber = normalizeTwilioPhone(phoneNumber) var channel channelmodel.ChannelTwilioSMS if err := h.db.Where("phone_number = ?", phoneNumber).First(&channel).Error; err != nil { diff --git a/internal/handler/webhook/webhook_lookup_test.go b/internal/handler/webhook/webhook_lookup_test.go index 04e4ee47..e5087265 100644 --- a/internal/handler/webhook/webhook_lookup_test.go +++ b/internal/handler/webhook/webhook_lookup_test.go @@ -69,6 +69,33 @@ func (l *recordingListener) OnEvent(ctx context.Context, event *channel.ChannelE return nil } +func TestValidProviderMessageStatusTransition(t *testing.T) { + tests := []struct { + name string + current model.MessageStatus + next model.MessageStatus + want bool + }{ + {name: "sent to delivered", current: model.MessageStatusSent, next: model.MessageStatusDelivered, want: true}, + {name: "delivered to read", current: model.MessageStatusDelivered, next: model.MessageStatusRead, want: true}, + {name: "sent to failed", current: model.MessageStatusSent, next: model.MessageStatusFailed, want: true}, + {name: "failed can be recovered by provider", current: model.MessageStatusFailed, next: model.MessageStatusDelivered, want: true}, + {name: "duplicate delivered is noop", current: model.MessageStatusDelivered, next: model.MessageStatusDelivered, want: false}, + {name: "duplicate failed is noop", current: model.MessageStatusFailed, next: model.MessageStatusFailed, want: false}, + {name: "read does not regress to delivered", current: model.MessageStatusRead, next: model.MessageStatusDelivered, want: false}, + {name: "delivered does not regress to sent", current: model.MessageStatusDelivered, next: model.MessageStatusSent, want: false}, + {name: "invalid next is rejected", current: model.MessageStatusSent, next: model.MessageStatus("queued"), want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := validProviderMessageStatusTransition(tt.current, tt.next); got != tt.want { + t.Fatalf("validProviderMessageStatusTransition(%q, %q) = %v, want %v", tt.current, tt.next, got, tt.want) + } + }) + } +} + func newWebhookLookupTestDB(t *testing.T) *gorm.DB { t.Helper() @@ -92,6 +119,7 @@ func newWebhookLookupTestDB(t *testing.T) *gorm.DB { &channelmodel.ChannelTwilioSMS{}, &channelmodel.ChannelWhatsApp{}, &channelmodel.ChannelTikTok{}, + &channelmodel.ChannelFacebook{}, &channelmodel.ChannelInstagram{}, &model.IntegrationHook{}, ); err != nil { @@ -190,6 +218,93 @@ func TestIncomingPersisterUpdatesMessageStatus(t *testing.T) { } } +func TestTwilioDeliveryStatusWebhookPersistsFailedStatusAndExternalError(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newWebhookLookupTestDB(t) + inbox := seedWebhookInbox(t, db, "twilio_sms") + twilioChannel := channelmodel.ChannelTwilioSMS{ + AccountID: inbox.AccountID, + InboxID: inbox.ID, + AccountSID: "ACtwilio", + PhoneNumber: "+15551234567", + MessagingServiceSID: "MGtwilio", + } + if err := db.Create(&twilioChannel).Error; err != nil { + t.Fatalf("create twilio channel: %v", err) + } + inbox.ChannelID = twilioChannel.ID + if err := db.Save(&inbox).Error; err != nil { + t.Fatalf("update inbox channel id: %v", err) + } + contact := model.Contact{AccountID: inbox.AccountID, Name: "Twilio Contact"} + if err := db.Create(&contact).Error; err != nil { + t.Fatalf("create contact: %v", err) + } + conversation := model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} + if err := db.Create(&conversation).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + message := model.Message{ + AccountID: inbox.AccountID, + InboxID: inbox.ID, + ConversationID: conversation.ID, + SenderType: "agent", + Content: "outbound sms", + ContentType: "text", + Status: string(model.MessageStatusSent), + MessageType: string(model.MessageTypeOutgoing), + SourceID: "SMtwilio", + } + if err := db.Create(&message).Error; err != nil { + t.Fatalf("create message: %v", err) + } + dispatcher := channel.NewDispatcher() + listener := &recordingListener{} + dispatcher.Register(listener) + handler := NewTwilioWebhookHandler(nil, db, dispatcher) + router := gin.New() + router.POST("/webhooks/twilio/status/:phone_number", handler.HandleTwilioDeliveryStatus) + + form := url.Values{ + "MessageSid": {"SMtwilio"}, + "MessageStatus": {"undelivered"}, + "ErrorCode": {"30007"}, + "ErrorMessage": {"Carrier violation"}, + } + recorder := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/webhooks/twilio/status/15551234567", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + router.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusNoContent { + t.Fatalf("expected Twilio no-content ack, got %d body=%s", recorder.Code, recorder.Body.String()) + } + var updated model.Message + if err := db.First(&updated, message.ID).Error; err != nil { + t.Fatalf("load updated message: %v", err) + } + if updated.Status != string(model.MessageStatusFailed) { + t.Fatalf("expected failed message status, got %s", updated.Status) + } + attrs := map[string]any{} + if err := json.Unmarshal(updated.ContentAttributes, &attrs); err != nil { + t.Fatalf("invalid content attributes: %v", err) + } + if attrs["external_error"] != "30007 - Carrier violation" { + t.Fatalf("expected Twilio external error, got %#v", attrs) + } + var delivery model.DeliveryStatus + if err := db.Where("message_id = ? AND contact_id = ?", updated.ID, contact.ID).First(&delivery).Error; err != nil { + t.Fatalf("expected delivery status: %v", err) + } + if delivery.Status != model.MessageStatusFailed { + t.Fatalf("expected failed delivery status, got %#v", delivery) + } + if !listenerSaw(listener, channel.EventMessageStatusUpdated) { + t.Fatalf("expected message.status_updated event, got %#v", listener.events) + } +} + func TestIncomingPersisterQueuesMessageStatusUpdateWithWorker(t *testing.T) { db := newWebhookLookupTestDB(t) inbox := seedWebhookInbox(t, db, "telegram") @@ -638,6 +753,40 @@ func seedWebhookInbox(t *testing.T, db *gorm.DB, channelType string) model.Inbox return inbox } +func seedInstagramReceiptConversation(t *testing.T, db *gorm.DB, instagramAccountID, contactSourceID string) (model.Inbox, model.Contact, model.Conversation) { + t.Helper() + + inbox := seedWebhookInbox(t, db, "instagram") + instagramChannel := channelmodel.ChannelInstagram{ + AccountID: inbox.AccountID, + InboxID: inbox.ID, + InstagramAccountID: instagramAccountID, + PageAccessToken: "page-token", + ConnectedFBPageID: "fb-page-for-" + instagramAccountID, + InstagramAccountName: "support_ig", + } + if err := db.Create(&instagramChannel).Error; err != nil { + t.Fatalf("create instagram channel: %v", err) + } + inbox.ChannelID = instagramChannel.ID + if err := db.Save(&inbox).Error; err != nil { + t.Fatalf("update instagram inbox channel id: %v", err) + } + contact := model.Contact{AccountID: inbox.AccountID, Name: "Instagram Contact"} + if err := db.Create(&contact).Error; err != nil { + t.Fatalf("create contact: %v", err) + } + contactInbox := model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: contactSourceID, PubsubToken: "pub-" + contactSourceID} + if err := db.Create(&contactInbox).Error; err != nil { + t.Fatalf("create contact inbox: %v", err) + } + conversation := model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} + if err := db.Create(&conversation).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + return inbox, contact, conversation +} + func TestTelegramWebhookLookupInboxByBotToken(t *testing.T) { db := newWebhookLookupTestDB(t) inbox := seedWebhookInbox(t, db, "telegram") @@ -697,6 +846,279 @@ func TestTelegramWebhookPersistsIncomingMessage(t *testing.T) { } } +func TestFacebookWebhookDeliveryReceiptPersistsDeliveredStatus(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newWebhookLookupTestDB(t) + inbox := seedWebhookInbox(t, db, "facebook") + facebookChannel := channelmodel.ChannelFacebook{ + AccountID: inbox.AccountID, + InboxID: inbox.ID, + PageID: "page-123", + PageAccessToken: "page-token", + PageName: "Support Page", + } + if err := db.Create(&facebookChannel).Error; err != nil { + t.Fatalf("create facebook channel: %v", err) + } + inbox.ChannelID = facebookChannel.ID + if err := db.Save(&inbox).Error; err != nil { + t.Fatalf("update inbox channel id: %v", err) + } + contact := model.Contact{AccountID: inbox.AccountID, Name: "Facebook Contact"} + if err := db.Create(&contact).Error; err != nil { + t.Fatalf("create contact: %v", err) + } + contactInbox := model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "fb-user-1", PubsubToken: "pub-fb"} + if err := db.Create(&contactInbox).Error; err != nil { + t.Fatalf("create contact inbox: %v", err) + } + conversation := model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} + if err := db.Create(&conversation).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + message := createWebhookStatusMessage(t, db, inbox, conversation, "fb-out-1", model.MessageStatusSent, time.Now().Add(-time.Minute).UTC()) + + dispatcher := channel.NewDispatcher() + listener := &recordingListener{} + dispatcher.Register(listener) + handler := NewFacebookWebhookHandler(nil, nil, db, dispatcher) + router := gin.New() + router.POST("/webhooks/facebook/:page_id", handler.HandleFacebookWebhook) + watermark := time.Now().UTC().UnixMilli() + body := []byte(`{"object":"page","entry":[{"id":"page-123","time":` + strconv.FormatInt(watermark, 10) + `,"messaging":[{"sender":{"id":"fb-user-1"},"recipient":{"id":"page-123"},"timestamp":` + strconv.FormatInt(watermark, 10) + `,"delivery":{"mids":["fb-out-1"],"watermark":` + strconv.FormatInt(watermark, 10) + `}}]}]}`) + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/webhooks/facebook/page-123", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected Facebook ack, got %d body=%s", recorder.Code, recorder.Body.String()) + } + assertWebhookMessageStatus(t, db, message.ID, model.MessageStatusDelivered) + var delivery model.DeliveryStatus + if err := db.Where("message_id = ? AND contact_id = ?", message.ID, contact.ID).First(&delivery).Error; err != nil { + t.Fatalf("expected delivery status: %v", err) + } + if delivery.Status != model.MessageStatusDelivered { + t.Fatalf("expected delivered delivery status, got %s", delivery.Status) + } + if delivery.DeliveredAt == nil { + t.Fatalf("expected delivered_at timestamp") + } + if !listenerSaw(listener, channel.EventMessageStatusUpdated) { + t.Fatalf("expected message.status_updated event, got %#v", listener.events) + } + retry := httptest.NewRecorder() + retryReq := httptest.NewRequest(http.MethodPost, "/webhooks/facebook/page-123", bytes.NewReader(body)) + retryReq.Header.Set("Content-Type", "application/json") + router.ServeHTTP(retry, retryReq) + if retry.Code != http.StatusOK { + t.Fatalf("expected duplicate Facebook ack, got %d body=%s", retry.Code, retry.Body.String()) + } + var deliveryCount int64 + if err := db.Model(&model.DeliveryStatus{}).Where("message_id = ? AND contact_id = ?", message.ID, contact.ID).Count(&deliveryCount).Error; err != nil { + t.Fatalf("count delivery statuses: %v", err) + } + if deliveryCount != 1 { + t.Fatalf("expected duplicate delivery receipt to keep one delivery status row, got %d", deliveryCount) + } + if len(listener.events) != 1 { + t.Fatalf("expected duplicate delivery receipt not to dispatch another status event, got %#v", listener.events) + } +} + +func TestFacebookWebhookReadReceiptPersistsReadStatusForContactConversation(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newWebhookLookupTestDB(t) + inbox := seedWebhookInbox(t, db, "facebook") + facebookChannel := channelmodel.ChannelFacebook{ + AccountID: inbox.AccountID, + InboxID: inbox.ID, + PageID: "page-read-123", + PageAccessToken: "page-token", + PageName: "Support Page", + } + if err := db.Create(&facebookChannel).Error; err != nil { + t.Fatalf("create facebook channel: %v", err) + } + inbox.ChannelID = facebookChannel.ID + if err := db.Save(&inbox).Error; err != nil { + t.Fatalf("update inbox channel id: %v", err) + } + contact := model.Contact{AccountID: inbox.AccountID, Name: "Facebook Read Contact"} + if err := db.Create(&contact).Error; err != nil { + t.Fatalf("create contact: %v", err) + } + contactInbox := model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "fb-user-read", PubsubToken: "pub-fb-read"} + if err := db.Create(&contactInbox).Error; err != nil { + t.Fatalf("create contact inbox: %v", err) + } + conversation := model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} + if err := db.Create(&conversation).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + cutoff := time.Now().UTC() + beforeSent := createWebhookStatusMessage(t, db, inbox, conversation, "fb-read-sent", model.MessageStatusSent, cutoff.Add(-time.Minute)) + beforeDelivered := createWebhookStatusMessage(t, db, inbox, conversation, "fb-read-delivered", model.MessageStatusDelivered, cutoff.Add(-30*time.Second)) + + dispatcher := channel.NewDispatcher() + listener := &recordingListener{} + dispatcher.Register(listener) + handler := NewFacebookWebhookHandler(nil, nil, db, dispatcher) + router := gin.New() + router.POST("/webhooks/facebook/:page_id", handler.HandleFacebookWebhook) + watermark := cutoff.UnixMilli() + body := []byte(`{"object":"page","entry":[{"id":"page-read-123","time":` + strconv.FormatInt(watermark, 10) + `,"messaging":[{"sender":{"id":"fb-user-read"},"recipient":{"id":"page-read-123"},"timestamp":` + strconv.FormatInt(watermark, 10) + `,"read":{"watermark":` + strconv.FormatInt(watermark, 10) + `}}]}]}`) + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/webhooks/facebook/page-read-123", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected Facebook ack, got %d body=%s", recorder.Code, recorder.Body.String()) + } + assertWebhookMessageStatus(t, db, beforeSent.ID, model.MessageStatusRead) + assertWebhookMessageStatus(t, db, beforeDelivered.ID, model.MessageStatusRead) + var readCount int64 + if err := db.Model(&model.DeliveryStatus{}).Where("contact_id = ? AND status = ?", contact.ID, model.MessageStatusRead).Count(&readCount).Error; err != nil { + t.Fatalf("count read delivery statuses: %v", err) + } + if readCount != 2 { + t.Fatalf("expected 2 read delivery statuses, got %d", readCount) + } + if !listenerSaw(listener, channel.EventMessageStatusUpdated) { + t.Fatalf("expected message.status_updated event, got %#v", listener.events) + } + retry := httptest.NewRecorder() + retryReq := httptest.NewRequest(http.MethodPost, "/webhooks/facebook/page-read-123", bytes.NewReader(body)) + retryReq.Header.Set("Content-Type", "application/json") + router.ServeHTTP(retry, retryReq) + if retry.Code != http.StatusOK { + t.Fatalf("expected duplicate Facebook read ack, got %d body=%s", retry.Code, retry.Body.String()) + } + if err := db.Model(&model.DeliveryStatus{}).Where("contact_id = ? AND status = ?", contact.ID, model.MessageStatusRead).Count(&readCount).Error; err != nil { + t.Fatalf("count read delivery statuses after retry: %v", err) + } + if readCount != 2 { + t.Fatalf("expected duplicate read receipt to keep two read delivery statuses, got %d", readCount) + } + if len(listener.events) != 2 { + t.Fatalf("expected duplicate read receipt not to dispatch more status events, got %#v", listener.events) + } +} + +func TestInstagramWebhookDeliveryReceiptPersistsDeliveredStatus(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("INSTAGRAM_APP_SECRET", "ig-secret") + db := newWebhookLookupTestDB(t) + inbox, contact, conversation := seedInstagramReceiptConversation(t, db, "ig-account-1", "ig-user-1") + message := createWebhookStatusMessage(t, db, inbox, conversation, "ig-out-1", model.MessageStatusSent, time.Now().Add(-time.Minute).UTC()) + + dispatcher := channel.NewDispatcher() + listener := &recordingListener{} + dispatcher.Register(listener) + handler := NewFacebookWebhookHandler(nil, nil, db, dispatcher) + router := gin.New() + router.POST("/webhooks/instagram", handler.HandleInstagramWebhook) + watermark := time.Now().UTC().UnixMilli() + body := []byte(`{"object":"instagram","entry":[{"id":"ig-account-1","time":` + strconv.FormatInt(watermark, 10) + `,"messaging":[{"sender":{"id":"ig-user-1"},"recipient":{"id":"ig-account-1"},"timestamp":` + strconv.FormatInt(watermark, 10) + `,"delivery":{"mids":["ig-out-1"],"watermark":` + strconv.FormatInt(watermark, 10) + `}}]}]}`) + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/webhooks/instagram", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Hub-Signature-256", metaSignature("ig-secret", body)) + router.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected Instagram ack, got %d body=%s", recorder.Code, recorder.Body.String()) + } + assertWebhookMessageStatus(t, db, message.ID, model.MessageStatusDelivered) + var delivery model.DeliveryStatus + if err := db.Where("message_id = ? AND contact_id = ?", message.ID, contact.ID).First(&delivery).Error; err != nil { + t.Fatalf("expected delivery status: %v", err) + } + if delivery.Status != model.MessageStatusDelivered || delivery.DeliveredAt == nil { + t.Fatalf("expected delivered status with timestamp, got %#v", delivery) + } + if !listenerSaw(listener, channel.EventMessageStatusUpdated) { + t.Fatalf("expected message.status_updated event, got %#v", listener.events) + } + retry := httptest.NewRecorder() + retryReq := httptest.NewRequest(http.MethodPost, "/webhooks/instagram", bytes.NewReader(body)) + retryReq.Header.Set("Content-Type", "application/json") + retryReq.Header.Set("X-Hub-Signature-256", metaSignature("ig-secret", body)) + router.ServeHTTP(retry, retryReq) + if retry.Code != http.StatusOK { + t.Fatalf("expected duplicate Instagram ack, got %d body=%s", retry.Code, retry.Body.String()) + } + var deliveryCount int64 + if err := db.Model(&model.DeliveryStatus{}).Where("message_id = ? AND contact_id = ?", message.ID, contact.ID).Count(&deliveryCount).Error; err != nil { + t.Fatalf("count delivery statuses: %v", err) + } + if deliveryCount != 1 { + t.Fatalf("expected duplicate delivery receipt to keep one delivery status row, got %d", deliveryCount) + } + if len(listener.events) != 1 { + t.Fatalf("expected duplicate delivery receipt not to dispatch another status event, got %#v", listener.events) + } +} + +func TestInstagramWebhookReadReceiptPersistsReadStatusForContactConversation(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("INSTAGRAM_APP_SECRET", "ig-secret") + db := newWebhookLookupTestDB(t) + inbox, contact, conversation := seedInstagramReceiptConversation(t, db, "ig-read-account", "ig-read-user") + cutoff := time.Now().UTC() + beforeSent := createWebhookStatusMessage(t, db, inbox, conversation, "ig-read-sent", model.MessageStatusSent, cutoff.Add(-time.Minute)) + beforeDelivered := createWebhookStatusMessage(t, db, inbox, conversation, "ig-read-delivered", model.MessageStatusDelivered, cutoff.Add(-30*time.Second)) + + dispatcher := channel.NewDispatcher() + listener := &recordingListener{} + dispatcher.Register(listener) + handler := NewFacebookWebhookHandler(nil, nil, db, dispatcher) + router := gin.New() + router.POST("/webhooks/instagram", handler.HandleInstagramWebhook) + watermark := cutoff.UnixMilli() + body := []byte(`{"object":"instagram","entry":[{"id":"ig-read-account","time":` + strconv.FormatInt(watermark, 10) + `,"messaging":[{"sender":{"id":"ig-read-user"},"recipient":{"id":"ig-read-account"},"timestamp":` + strconv.FormatInt(watermark, 10) + `,"read":{"watermark":` + strconv.FormatInt(watermark, 10) + `}}]}]}`) + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/webhooks/instagram", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Hub-Signature-256", metaSignature("ig-secret", body)) + router.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected Instagram ack, got %d body=%s", recorder.Code, recorder.Body.String()) + } + assertWebhookMessageStatus(t, db, beforeSent.ID, model.MessageStatusRead) + assertWebhookMessageStatus(t, db, beforeDelivered.ID, model.MessageStatusRead) + var readCount int64 + if err := db.Model(&model.DeliveryStatus{}).Where("contact_id = ? AND status = ?", contact.ID, model.MessageStatusRead).Count(&readCount).Error; err != nil { + t.Fatalf("count read delivery statuses: %v", err) + } + if readCount != 2 { + t.Fatalf("expected 2 read delivery statuses, got %d", readCount) + } + if !listenerSaw(listener, channel.EventMessageStatusUpdated) { + t.Fatalf("expected message.status_updated event, got %#v", listener.events) + } + retry := httptest.NewRecorder() + retryReq := httptest.NewRequest(http.MethodPost, "/webhooks/instagram", bytes.NewReader(body)) + retryReq.Header.Set("Content-Type", "application/json") + retryReq.Header.Set("X-Hub-Signature-256", metaSignature("ig-secret", body)) + router.ServeHTTP(retry, retryReq) + if retry.Code != http.StatusOK { + t.Fatalf("expected duplicate Instagram read ack, got %d body=%s", retry.Code, retry.Body.String()) + } + if err := db.Model(&model.DeliveryStatus{}).Where("contact_id = ? AND status = ?", contact.ID, model.MessageStatusRead).Count(&readCount).Error; err != nil { + t.Fatalf("count read delivery statuses after retry: %v", err) + } + if readCount != 2 { + t.Fatalf("expected duplicate read receipt to keep two read delivery statuses, got %d", readCount) + } + if len(listener.events) != 2 { + t.Fatalf("expected duplicate read receipt not to dispatch more status events, got %#v", listener.events) + } +} + func TestLineWebhookLookupInboxByLineChannelID(t *testing.T) { db := newWebhookLookupTestDB(t) inbox := seedWebhookInbox(t, db, "line") @@ -791,6 +1213,45 @@ func TestLineWebhookRejectsMissingSignatureWhenSecretConfigured(t *testing.T) { } } +func TestLineWebhookRejectsInvalidSignatureWhenSecretConfigured(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newWebhookLookupTestDB(t) + inbox := seedWebhookInbox(t, db, "line") + inbox.ChannelConfig = `{"channel_secret":"line-secret"}` + if err := db.Save(&inbox).Error; err != nil { + t.Fatalf("update line inbox config: %v", err) + } + channelRecord := channelmodel.ChannelLINE{AccountID: 1, InboxID: inbox.ID, ChannelID: "line-channel-1", Name: "LINE OA"} + if err := db.Create(&channelRecord).Error; err != nil { + t.Fatalf("create line channel: %v", err) + } + + lineRepo := linechannel.NewRepository(db) + lineService := linechannel.NewLineService(lineRepo) + linePipeline := linechannel.NewIncomingProcessor(lineService) + h := NewLineWebhookHandler(nil, linePipeline, lineService, db) + r := gin.New() + r.POST("/webhooks/line/:line_channel_id", h.HandleLineWebhook) + body := []byte(`{"destination":"line-channel-1","events":[{"type":"message","replyToken":"reply-1","timestamp":1710000000000,"source":{"type":"user","userId":"line-user-1"},"message":{"type":"text","id":"line-msg-bad-sig","text":"hello line"}}]}`) + req := httptest.NewRequest(http.MethodPost, "/webhooks/line/line-channel-1", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Line-Signature", lineSignature("wrong-secret", body)) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d body=%s", w.Code, w.Body.String()) + } + var count int64 + if err := db.Model(&model.Message{}).Where("inbox_id = ? AND source_id = ?", inbox.ID, "line-msg-bad-sig").Count(&count).Error; err != nil { + t.Fatalf("count message: %v", err) + } + if count != 0 { + t.Fatalf("expected no persisted message, got %d", count) + } +} + func TestTwilioWebhookLookupInboxByPhoneNumber(t *testing.T) { db := newWebhookLookupTestDB(t) inbox := seedWebhookInbox(t, db, "twilio_sms") @@ -966,7 +1427,7 @@ func TestTwilioInboundSMSQueuesIncomingMessageWithWorker(t *testing.T) { assertPersistedMessage(t, db, inbox.ID, "SMINASYNC1", "queued sms") } -func TestTwilioDeliveryStatusUpdatesExistingMessage(t *testing.T) { +func TestTwilioDeliveryStatusPersistsDeliveredAndReadStatuses(t *testing.T) { gin.SetMode(gin.TestMode) db := newWebhookLookupTestDB(t) inbox := seedWebhookInbox(t, db, "twilio_sms") @@ -986,29 +1447,71 @@ func TestTwilioDeliveryStatusUpdatesExistingMessage(t *testing.T) { if err := db.Create(&conversation).Error; err != nil { t.Fatalf("create conversation: %v", err) } - message := model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, SenderID: &contact.ID, SenderType: string(model.SenderTypeContact), Content: "out", ContentType: string(model.MessageContentTypeText), MessageType: string(model.MessageTypeOutgoing), Status: string(model.MessageStatusSent), SourceID: "SM123"} - if err := db.Create(&message).Error; err != nil { - t.Fatalf("create message: %v", err) + deliveredMessage := model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, SenderID: &contact.ID, SenderType: string(model.SenderTypeContact), Content: "delivered", ContentType: string(model.MessageContentTypeText), MessageType: string(model.MessageTypeOutgoing), Status: string(model.MessageStatusSent), SourceID: "SMDELIVERED"} + readMessage := model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, SenderID: &contact.ID, SenderType: string(model.SenderTypeContact), Content: "read", ContentType: string(model.MessageContentTypeText), MessageType: string(model.MessageTypeOutgoing), Status: string(model.MessageStatusDelivered), SourceID: "SMREAD"} + if err := db.Create(&deliveredMessage).Error; err != nil { + t.Fatalf("create delivered message: %v", err) + } + if err := db.Create(&readMessage).Error; err != nil { + t.Fatalf("create read message: %v", err) } - h := NewTwilioWebhookHandler(nil, db) + dispatcher := channel.NewDispatcher() + listener := &recordingListener{} + dispatcher.Register(listener) + h := NewTwilioWebhookHandler(nil, db, dispatcher) r := gin.New() r.POST("/webhooks/twilio/status/:phone_number", h.HandleTwilioDeliveryStatus) - req := httptest.NewRequest(http.MethodPost, "/webhooks/twilio/status/+15551234567", strings.NewReader("MessageSid=SM123&MessageStatus=delivered")) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - w := httptest.NewRecorder() + for _, form := range []string{ + "MessageSid=SMDELIVERED&MessageStatus=delivered", + "MessageSid=SMREAD&MessageStatus=read", + } { + req := httptest.NewRequest(http.MethodPost, "/webhooks/twilio/status/+15551234567", strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() - r.ServeHTTP(w, req) + r.ServeHTTP(w, req) - if w.Code != http.StatusNoContent { - t.Fatalf("expected 204, got %d", w.Code) + if w.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d", w.Code) + } } - var updated model.Message - if err := db.First(&updated, message.ID).Error; err != nil { - t.Fatalf("load message: %v", err) + assertWebhookMessageStatus(t, db, deliveredMessage.ID, model.MessageStatusDelivered) + assertWebhookMessageStatus(t, db, readMessage.ID, model.MessageStatusRead) + var delivered model.DeliveryStatus + if err := db.Where("message_id = ? AND contact_id = ?", deliveredMessage.ID, contact.ID).First(&delivered).Error; err != nil { + t.Fatalf("expected delivered delivery status: %v", err) } - if updated.Status != string(model.MessageStatusDelivered) { - t.Fatalf("expected delivered, got %s", updated.Status) + if delivered.Status != model.MessageStatusDelivered { + t.Fatalf("expected delivered delivery status, got %#v", delivered) + } + var read model.DeliveryStatus + if err := db.Where("message_id = ? AND contact_id = ?", readMessage.ID, contact.ID).First(&read).Error; err != nil { + t.Fatalf("expected read delivery status: %v", err) + } + if read.Status != model.MessageStatusRead { + t.Fatalf("expected read delivery status, got %#v", read) + } + if !listenerSaw(listener, channel.EventMessageStatusUpdated) { + t.Fatalf("expected message.status_updated event, got %#v", listener.events) + } + + duplicate := httptest.NewRecorder() + duplicateReq := httptest.NewRequest(http.MethodPost, "/webhooks/twilio/status/+15551234567", strings.NewReader("MessageSid=SMDELIVERED&MessageStatus=delivered")) + duplicateReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.ServeHTTP(duplicate, duplicateReq) + if duplicate.Code != http.StatusNoContent { + t.Fatalf("expected duplicate callback 204, got %d", duplicate.Code) + } + var deliveredCount int64 + if err := db.Model(&model.DeliveryStatus{}).Where("message_id = ? AND contact_id = ?", deliveredMessage.ID, contact.ID).Count(&deliveredCount).Error; err != nil { + t.Fatalf("count delivered status rows: %v", err) + } + if deliveredCount != 1 { + t.Fatalf("expected duplicate callback to keep one delivery status row, got %d", deliveredCount) + } + if len(listener.events) != 2 { + t.Fatalf("expected duplicate callback not to dispatch another status event, got %#v", listener.events) } } @@ -1113,6 +1616,157 @@ func TestWhatsAppWebhookPersistsIncomingMessage(t *testing.T) { assertPersistedMessage(t, db, inbox.ID, "wamid-1", "hello whatsapp") } +func TestWhatsAppWebhookPersistsDeliveryStatusAndFailureError(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newWebhookLookupTestDB(t) + inbox := seedWebhookInbox(t, db, "whatsapp") + waChannel := channelmodel.ChannelWhatsApp{AccountID: inbox.AccountID, InboxID: inbox.ID, PhoneNumber: "+15551230000", PhoneNumberID: "phone-id-1", AccessToken: "token", Provider: "whatsapp_cloud", ProviderConfig: `{"app_secret":"wa-secret"}`, WebhookVerifyToken: "verify-token"} + if err := db.Create(&waChannel).Error; err != nil { + t.Fatalf("create whatsapp channel: %v", err) + } + contact := model.Contact{AccountID: inbox.AccountID, Name: "WhatsApp Contact", Identifier: "15550001111"} + if err := db.Create(&contact).Error; err != nil { + t.Fatalf("create contact: %v", err) + } + conversation := model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} + if err := db.Create(&conversation).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + message := model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, SenderID: &contact.ID, SenderType: string(model.SenderTypeContact), Content: "out", ContentType: string(model.MessageContentTypeText), MessageType: string(model.MessageTypeOutgoing), Status: string(model.MessageStatusSent), SourceID: "wamid-out-1"} + if err := db.Create(&message).Error; err != nil { + t.Fatalf("create message: %v", err) + } + + waRepo := whatsappchannel.NewRepository(db) + waService := whatsappchannel.NewWhatsAppService(waRepo) + waPipeline := whatsappchannel.NewIncomingPipeline(waService) + waProvider := whatsappchannel.NewWhatsAppProvider(waService, waRepo, waPipeline) + waWebhook := whatsappchannel.NewWebhookHandler(waProvider) + h := NewWhatsAppWebhookHandler(waProvider, waWebhook, db) + r := gin.New() + r.POST("/webhooks/whatsapp/:phone_number", h.HandleWhatsAppWebhook) + body := []byte(`{"object":"whatsapp_business_account","entry":[{"id":"waba-1","changes":[{"field":"messages","value":{"messaging_product":"whatsapp","metadata":{"display_phone_number":"+15551230000","phone_number_id":"phone-id-1"},"statuses":[{"id":"wamid-out-1","status":"failed","timestamp":"1710000100","recipient_id":"15550001111","errors":[{"code":131047,"title":"Re-engagement message","message":"Message failed"}]}]}}]}]}`) + req := httptest.NewRequest(http.MethodPost, "/webhooks/whatsapp/+15551230000", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Hub-Signature-256", metaSignature("wa-secret", body)) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) + } + var updated model.Message + if err := db.First(&updated, message.ID).Error; err != nil { + t.Fatalf("load updated message: %v", err) + } + if updated.Status != string(model.MessageStatusFailed) { + t.Fatalf("expected failed status, got %s", updated.Status) + } + attrs := map[string]any{} + if err := json.Unmarshal(updated.ContentAttributes, &attrs); err != nil { + t.Fatalf("unmarshal attrs: %v", err) + } + if attrs["external_error"] != "131047 - Re-engagement message" { + t.Fatalf("expected whatsapp external error, got %#v", attrs) + } + var delivery model.DeliveryStatus + if err := db.Where("message_id = ? AND contact_id = ?", updated.ID, contact.ID).First(&delivery).Error; err != nil { + t.Fatalf("expected delivery status: %v", err) + } + if delivery.Status != model.MessageStatusFailed { + t.Fatalf("expected delivery failed, got %s", delivery.Status) + } +} + +func TestWhatsAppWebhookPersistsDeliveredAndReadStatuses(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newWebhookLookupTestDB(t) + inbox := seedWebhookInbox(t, db, "whatsapp") + waChannel := channelmodel.ChannelWhatsApp{AccountID: inbox.AccountID, InboxID: inbox.ID, PhoneNumber: "+15551230000", PhoneNumberID: "phone-id-1", AccessToken: "token", Provider: "whatsapp_cloud", ProviderConfig: `{"app_secret":"wa-secret"}`, WebhookVerifyToken: "verify-token"} + if err := db.Create(&waChannel).Error; err != nil { + t.Fatalf("create whatsapp channel: %v", err) + } + contact := model.Contact{AccountID: inbox.AccountID, Name: "WhatsApp Status Contact", Identifier: "15550002222"} + if err := db.Create(&contact).Error; err != nil { + t.Fatalf("create contact: %v", err) + } + conversation := model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} + if err := db.Create(&conversation).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + deliveredMessage := model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, SenderID: &contact.ID, SenderType: string(model.SenderTypeContact), Content: "delivered", ContentType: string(model.MessageContentTypeText), MessageType: string(model.MessageTypeOutgoing), Status: string(model.MessageStatusSent), SourceID: "wamid-delivered-1"} + readMessage := model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, SenderID: &contact.ID, SenderType: string(model.SenderTypeContact), Content: "read", ContentType: string(model.MessageContentTypeText), MessageType: string(model.MessageTypeOutgoing), Status: string(model.MessageStatusDelivered), SourceID: "wamid-read-1"} + if err := db.Create(&deliveredMessage).Error; err != nil { + t.Fatalf("create delivered message: %v", err) + } + if err := db.Create(&readMessage).Error; err != nil { + t.Fatalf("create read message: %v", err) + } + + waRepo := whatsappchannel.NewRepository(db) + waService := whatsappchannel.NewWhatsAppService(waRepo) + waPipeline := whatsappchannel.NewIncomingPipeline(waService) + waProvider := whatsappchannel.NewWhatsAppProvider(waService, waRepo, waPipeline) + waWebhook := whatsappchannel.NewWebhookHandler(waProvider) + dispatcher := channel.NewDispatcher() + listener := &recordingListener{} + dispatcher.Register(listener) + h := NewWhatsAppWebhookHandler(waProvider, waWebhook, db, dispatcher) + r := gin.New() + r.POST("/webhooks/whatsapp/:phone_number", h.HandleWhatsAppWebhook) + body := []byte(`{"object":"whatsapp_business_account","entry":[{"id":"waba-1","changes":[{"field":"messages","value":{"messaging_product":"whatsapp","metadata":{"display_phone_number":"+15551230000","phone_number_id":"phone-id-1"},"statuses":[{"id":"wamid-delivered-1","status":"delivered","timestamp":"1710000200","recipient_id":"15550002222"},{"id":"wamid-read-1","status":"read","timestamp":"1710000300","recipient_id":"15550002222"}]}}]}]}`) + req := httptest.NewRequest(http.MethodPost, "/webhooks/whatsapp/+15551230000", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Hub-Signature-256", metaSignature("wa-secret", body)) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) + } + assertWebhookMessageStatus(t, db, deliveredMessage.ID, model.MessageStatusDelivered) + assertWebhookMessageStatus(t, db, readMessage.ID, model.MessageStatusRead) + var delivered model.DeliveryStatus + if err := db.Where("message_id = ? AND contact_id = ?", deliveredMessage.ID, contact.ID).First(&delivered).Error; err != nil { + t.Fatalf("expected delivered delivery status: %v", err) + } + if delivered.Status != model.MessageStatusDelivered || delivered.DeliveredAt == nil { + t.Fatalf("expected delivered status timestamp, got %#v", delivered) + } + var read model.DeliveryStatus + if err := db.Where("message_id = ? AND contact_id = ?", readMessage.ID, contact.ID).First(&read).Error; err != nil { + t.Fatalf("expected read delivery status: %v", err) + } + if read.Status != model.MessageStatusRead || read.ReadAt == nil { + t.Fatalf("expected read status timestamp, got %#v", read) + } + if !listenerSaw(listener, channel.EventMessageStatusUpdated) { + t.Fatalf("expected message.status_updated event, got %#v", listener.events) + } + + duplicateBody := []byte(`{"object":"whatsapp_business_account","entry":[{"id":"waba-1","changes":[{"field":"messages","value":{"messaging_product":"whatsapp","metadata":{"display_phone_number":"+15551230000","phone_number_id":"phone-id-1"},"statuses":[{"id":"wamid-delivered-1","status":"delivered","timestamp":"1710000400","recipient_id":"15550002222"}]}}]}]}`) + duplicateReq := httptest.NewRequest(http.MethodPost, "/webhooks/whatsapp/+15551230000", bytes.NewReader(duplicateBody)) + duplicateReq.Header.Set("Content-Type", "application/json") + duplicateReq.Header.Set("X-Hub-Signature-256", metaSignature("wa-secret", duplicateBody)) + duplicate := httptest.NewRecorder() + r.ServeHTTP(duplicate, duplicateReq) + if duplicate.Code != http.StatusOK { + t.Fatalf("expected duplicate callback 200, got %d body=%s", duplicate.Code, duplicate.Body.String()) + } + var deliveredCount int64 + if err := db.Model(&model.DeliveryStatus{}).Where("message_id = ? AND contact_id = ?", deliveredMessage.ID, contact.ID).Count(&deliveredCount).Error; err != nil { + t.Fatalf("count delivered status rows: %v", err) + } + if deliveredCount != 1 { + t.Fatalf("expected duplicate callback to keep one delivery status row, got %d", deliveredCount) + } + if len(listener.events) != 2 { + t.Fatalf("expected duplicate callback not to dispatch another status event, got %#v", listener.events) + } +} + func TestWhatsAppWebhookVerificationEchoesChallenge(t *testing.T) { gin.SetMode(gin.TestMode) db := newWebhookLookupTestDB(t) @@ -1237,6 +1891,85 @@ func TestTikTokWebhookPersistsIncomingMessage(t *testing.T) { assertPersistedMessage(t, db, inbox.ID, "tt-msg-1", "hello tiktok") } +func TestTikTokWebhookReadReceiptUpdatesMessageStatus(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("TIKTOK_APP_SECRET", "tiktok-secret") + db := newWebhookLookupTestDB(t) + inbox := seedWebhookInbox(t, db, "tiktok") + channelRecord := channelmodel.ChannelTikTok{AccountID: 1, InboxID: inbox.ID, TikTokBusinessID: "biz-123", WebhookVerifyToken: "verify-token"} + if err := db.Create(&channelRecord).Error; err != nil { + t.Fatalf("create tiktok channel: %v", err) + } + contact := model.Contact{AccountID: inbox.AccountID, Name: "TikTok Contact"} + if err := db.Create(&contact).Error; err != nil { + t.Fatalf("create contact: %v", err) + } + conversation := model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} + if err := db.Create(&conversation).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + message := model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, SenderID: &contact.ID, SenderType: string(model.SenderTypeContact), Content: "outbound tiktok", ContentType: string(model.MessageContentTypeText), MessageType: string(model.MessageTypeOutgoing), Status: string(model.MessageStatusDelivered), SourceID: "tt-out-1"} + if err := db.Create(&message).Error; err != nil { + t.Fatalf("create message: %v", err) + } + dispatcher := channel.NewDispatcher() + listener := &recordingListener{} + dispatcher.Register(listener) + ttRepo := tiktokchannel.NewRepository(db) + ttService := tiktokchannel.NewTikTokService(ttRepo) + ttPipeline := tiktokchannel.NewIncomingProcessor(ttService, ttRepo) + ttWebhook := tiktokchannel.NewWebhookHandler(ttService, ttPipeline) + h := NewTikTokWebhookHandler(ttWebhook, ttPipeline, db, dispatcher) + r := gin.New() + r.POST("/webhooks/tiktok", h.HandleTikTokWebhook) + body := []byte(`{"type":"message.read","timestamp":1710000001,"biz_id":"biz-123","data":{"message_id":"tt-out-1","from_user_id":"tt-user-1","timestamp":1710000001}}`) + req := httptest.NewRequest(http.MethodPost, "/webhooks/tiktok", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Tiktok-Signature", tiktokSignature("tiktok-secret", time.Now().Unix(), body)) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) + } + var updated model.Message + if err := db.First(&updated, message.ID).Error; err != nil { + t.Fatalf("load updated message: %v", err) + } + if updated.Status != string(model.MessageStatusRead) { + t.Fatalf("expected read message status, got %s", updated.Status) + } + var delivery model.DeliveryStatus + if err := db.Where("message_id = ? AND contact_id = ?", updated.ID, contact.ID).First(&delivery).Error; err != nil { + t.Fatalf("expected delivery status: %v", err) + } + if delivery.Status != model.MessageStatusRead { + t.Fatalf("expected read delivery status, got %#v", delivery) + } + if !listenerSaw(listener, channel.EventMessageStatusUpdated) { + t.Fatalf("expected message.status_updated event, got %#v", listener.events) + } + retry := httptest.NewRecorder() + retryReq := httptest.NewRequest(http.MethodPost, "/webhooks/tiktok", bytes.NewReader(body)) + retryReq.Header.Set("Content-Type", "application/json") + retryReq.Header.Set("Tiktok-Signature", tiktokSignature("tiktok-secret", time.Now().Unix(), body)) + r.ServeHTTP(retry, retryReq) + if retry.Code != http.StatusOK { + t.Fatalf("expected duplicate TikTok read ack, got %d body=%s", retry.Code, retry.Body.String()) + } + var deliveryCount int64 + if err := db.Model(&model.DeliveryStatus{}).Where("message_id = ? AND contact_id = ?", updated.ID, contact.ID).Count(&deliveryCount).Error; err != nil { + t.Fatalf("count delivery statuses after retry: %v", err) + } + if deliveryCount != 1 { + t.Fatalf("expected duplicate read receipt to keep one delivery status row, got %d", deliveryCount) + } + if len(listener.events) != 1 { + t.Fatalf("expected duplicate read receipt not to dispatch another status event, got %#v", listener.events) + } +} + func TestTikTokWebhookRejectsInvalidSignature(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("TIKTOK_APP_SECRET", "tiktok-secret") diff --git a/internal/handler/webhook/whatsapp_webhook.go b/internal/handler/webhook/whatsapp_webhook.go index dad427ab..f54e86ac 100644 --- a/internal/handler/webhook/whatsapp_webhook.go +++ b/internal/handler/webhook/whatsapp_webhook.go @@ -97,6 +97,10 @@ func (a whatsAppPersisterAdapter) UpdateMessageStatus(ctx context.Context, inbox return a.persister.UpdateMessageStatus(ctx, inbox, sourceID, status, occurredAt) } +func (a whatsAppPersisterAdapter) UpdateMessageStatusWithError(ctx context.Context, inbox *model.Inbox, sourceID string, status model.MessageStatus, occurredAt *time.Time, externalError string) error { + return a.persister.UpdateMessageStatusWithError(ctx, inbox, sourceID, status, occurredAt, externalError) +} + // HandleWhatsAppVerification handles GET requests for WhatsApp Cloud API // webhook verification. Meta sends this request during initial webhook setup // and periodic re-verification. It delegates to the underlying diff --git a/internal/handler/widget/widget_handler.go b/internal/handler/widget/widget_handler.go index 752bc79e..264a8fdd 100644 --- a/internal/handler/widget/widget_handler.go +++ b/internal/handler/widget/widget_handler.go @@ -11,14 +11,20 @@ import ( "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" + wspkg "github.com/gochat/gochat/internal/ws" ) +type widgetEventPublisher interface { + PublishWidgetEvent(accountID uint, pubsubToken string, eventType string, payload interface{}) +} + // WidgetHandler handles the public-facing widget API endpoints. // These endpoints are accessed by the embedded JS widget on customer websites // and do not require agent JWT authentication — they use a widget_token instead. // Reference: Chatwoot app/controllers/api/v1/widget_messages_controller.rb type WidgetHandler struct { - widgetService *service.WidgetService + widgetService *service.WidgetService + eventPublisher widgetEventPublisher } // NewHandler creates a new WidgetHandler with the WidgetService dependency. @@ -28,6 +34,11 @@ func NewHandler(widgetService *service.WidgetService) *WidgetHandler { } } +func (h *WidgetHandler) WithEventPublisher(publisher widgetEventPublisher) *WidgetHandler { + h.eventPublisher = publisher + return h +} + // Init handles widget initialization — authenticates/creates a contact // and returns a widget_token (pubsub_token) for subsequent requests. // POST /widget/init @@ -42,6 +53,9 @@ func (h *WidgetHandler) Init(c *gin.Context) { if req.WebsiteToken == "" { req.WebsiteToken = c.Query("website_token") } + if req.WidgetToken == "" { + req.WidgetToken = widgetTokenFromRequest(c) + } // HMAC verification: if the client provides an identifier + identifier_hash, // verify the hash against the inbox's hmac_token. This mirrors Chatwoot's @@ -88,6 +102,9 @@ func (h *WidgetHandler) Config(c *gin.Context) { if req.WebsiteToken == "" { req.WebsiteToken = c.Query("website_token") } + if req.WidgetToken == "" { + req.WidgetToken = widgetTokenFromRequest(c) + } resp, err := h.widgetService.Init(c.Request.Context(), req) if err != nil { @@ -114,16 +131,29 @@ func (h *WidgetHandler) Config(c *gin.Context) { contact["phone_number"] = resp.Contact.PhoneNumber } + channelConfig := gin.H{ + "auth_token": resp.WidgetToken, + "website_token": resp.WidgetConfig.WebsiteToken, + "widget_color": resp.WidgetConfig.WidgetColor, + "welcome_title": resp.WidgetConfig.WelcomeTitle, + "welcome_tagline": resp.WidgetConfig.WelcomeTagline, + "website_name": resp.InboxName, + "enabledFeatures": widgetEnabledFeatures(resp.WidgetConfig), + } + if resp.WidgetConfig.PreChatFieldsEnabled { + channelConfig["preChatFormEnabled"] = true + channelConfig["preChatFormOptions"] = gin.H{ + "pre_chat_message": resp.WidgetConfig.PreChatMessage, + "pre_chat_fields": defaultWidgetPreChatFields(), + } + } else { + channelConfig["preChatFormEnabled"] = false + channelConfig["preChatFormOptions"] = gin.H{"pre_chat_message": "", "pre_chat_fields": []gin.H{}} + } + c.JSON(http.StatusOK, gin.H{ - "website_channel_config": gin.H{ - "auth_token": resp.WidgetToken, - "website_token": resp.WidgetConfig.WebsiteToken, - "widget_color": resp.WidgetConfig.WidgetColor, - "welcome_title": resp.WidgetConfig.WelcomeTitle, - "welcome_tagline": resp.WidgetConfig.WelcomeTagline, - "website_name": resp.InboxName, - }, - "contact": contact, + "website_channel_config": channelConfig, + "contact": contact, "global_config": gin.H{ "directUploadsEnabled": true, "maximumFileUploadSize": 40, @@ -131,6 +161,20 @@ func (h *WidgetHandler) Config(c *gin.Context) { }) } +func defaultWidgetPreChatFields() []gin.H { + return []gin.H{ + {"label": "Email Id", "name": "emailAddress", "type": "email", "field_type": "standard", "required": false, "enabled": true}, + {"label": "Full name", "name": "fullName", "type": "text", "field_type": "standard", "required": true, "enabled": true}, + } +} + +func widgetEnabledFeatures(config service.WebWidgetConfig) []string { + if len(config.SelectedFeatureFlags) > 0 { + return config.SelectedFeatureFlags + } + return []string{"attachments", "emoji_picker", "end_conversation"} +} + // SendMessage sends a message from the widget contact to the conversation. // POST /widget/messages // Reference: Chatwoot widget SDK — send message endpoint @@ -163,6 +207,11 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) { } if c.FullPath() == "/widget/messages" { + payload := widgetMessagePayload(resp.Message, resp.ConversationID) + if len(resp.Attachments) > 0 { + payload["attachments"] = widgetAttachmentPayloads(resp.Attachments) + } + h.publishWidgetMessageEvent(req.WidgetToken, payload) c.JSON(http.StatusOK, resp) return } @@ -170,6 +219,7 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) { if len(resp.Attachments) > 0 { payload["attachments"] = widgetAttachmentPayloads(resp.Attachments) } + h.publishWidgetMessageEvent(req.WidgetToken, payload) c.JSON(http.StatusOK, payload) } @@ -211,6 +261,40 @@ func (h *WidgetHandler) UpdateMessage(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"contact": widgetContactFullPayload(contact)}) } +func (h *WidgetHandler) publishWidgetMessageEvent(pubsubToken string, payload gin.H) { + if h.eventPublisher == nil || payload == nil { + return + } + accountID := uintFromPayload(payload["account_id"]) + if accountID == 0 { + accountID = uintFromPayload(payload["accountId"]) + } + if accountID == 0 { + return + } + h.eventPublisher.PublishWidgetEvent(accountID, pubsubToken, wspkg.EventMessageCreated, payload) +} + +func uintFromPayload(value any) uint { + switch typed := value.(type) { + case uint: + return typed + case int: + if typed > 0 { + return uint(typed) + } + case int64: + if typed > 0 { + return uint(typed) + } + case float64: + if typed > 0 { + return uint(typed) + } + } + return 0 +} + // GetLatestMessages implements Chatwoot's GET /api/v1/widget/messages endpoint. func (h *WidgetHandler) GetLatestMessages(c *gin.Context) { widgetToken := widgetTokenFromRequest(c) @@ -1128,6 +1212,12 @@ func widgetTokenFromRequest(c *gin.Context) string { if token := c.GetHeader("X-Auth-Token"); token != "" { return token } + if token := c.Query("cw_conversation"); token != "" { + return token + } + if cookie, err := c.Cookie("cw_conversation"); err == nil && cookie != "" { + return cookie + } return c.Query("widget_token") } @@ -1227,6 +1317,7 @@ func firstFormValue(values map[string][]string, keys ...string) string { func widgetMessagePayload(message model.Message, conversationID uint) gin.H { return gin.H{ "id": message.ID, + "account_id": message.AccountID, "content": message.Content, "inbox_id": message.InboxID, "conversation_id": conversationID, diff --git a/internal/handler/widget/widget_handler_test.go b/internal/handler/widget/widget_handler_test.go index aedb9dad..836efe4b 100644 --- a/internal/handler/widget/widget_handler_test.go +++ b/internal/handler/widget/widget_handler_test.go @@ -43,6 +43,20 @@ func (n *noopTypingIndicatorWidget) SetTypingOff(_ context.Context, _ uint, _ ui return nil } +type recordingWidgetEventPublisher struct { + accountID uint + pubsubToken string + eventType string + payload interface{} +} + +func (p *recordingWidgetEventPublisher) PublishWidgetEvent(accountID uint, pubsubToken string, eventType string, payload interface{}) { + p.accountID = accountID + p.pubsubToken = pubsubToken + p.eventType = eventType + p.payload = payload +} + type recordingWidgetTranscriptDeliverer struct { requests []automation.AutomationTranscriptRequest err error @@ -332,13 +346,61 @@ func TestWidgetHandler_ChatwootConfig_Success(t *testing.T) { require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) config := resp["website_channel_config"].(map[string]interface{}) + assertWidgetConfigFixtureShape(t, config) assert.NotEmpty(t, config["auth_token"]) assert.Equal(t, "handler_ws_token_123", config["website_token"]) assert.Equal(t, "Handler Widget Inbox", config["website_name"]) contact := resp["contact"].(map[string]interface{}) + assertWidgetConfigContactFixtureShape(t, contact) assert.NotEmpty(t, contact["id"]) assert.NotEmpty(t, contact["pubsub_token"]) + + globalConfig := resp["global_config"].(map[string]interface{}) + assert.Contains(t, globalConfig, "directUploadsEnabled") + assert.Contains(t, globalConfig, "maximumFileUploadSize") +} + +func TestWidgetHandler_ChatwootConfigPreChatFormOptionsMatchFrontendMixin(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, inbox := seedWidgetHandlerData(t, db) + channelConfig := map[string]interface{}{ + "website_token": "handler_ws_token_123", + "hmac_token": "handler_hmac_secret", + "widget_color": "#1f93ff", + "welcome_title": "Hi", + "welcome_tagline": "We reply fast", + "pre_chat_fields_enabled": true, + "pre_chat_message": "Tell us about yourself", + "selected_feature_flags": []string{"attachments", "emoji_picker", "end_conversation"}, + "continuity_via_email": true, + "offline_message_enabled": true, + "offline_message_title": "We are away", + "offline_message_description": "Leave a message", + } + configJSON, err := json.Marshal(channelConfig) + require.NoError(t, err) + require.NoError(t, db.Model(inbox).Update("channel_config", string(configJSON)).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + config := resp["website_channel_config"].(map[string]interface{}) + assert.True(t, config["preChatFormEnabled"].(bool)) + options := config["preChatFormOptions"].(map[string]interface{}) + assert.Equal(t, "Tell us about yourself", options["pre_chat_message"]) + fields := options["pre_chat_fields"].([]interface{}) + require.NotEmpty(t, fields) + firstField := fields[0].(map[string]interface{}) + for _, key := range []string{"label", "name", "type", "field_type", "required", "enabled"} { + assert.Contains(t, firstField, key) + } + assert.Equal(t, true, firstField["enabled"]) + assert.Contains(t, config["enabledFeatures"], "attachments") } func TestWidgetHandler_ChatwootConfig_InvalidWebsiteTokenReturnsNotFound(t *testing.T) { @@ -389,6 +451,144 @@ func TestWidgetHandler_ChatwootConfig_InvalidAuthTokenCreatesNewContact(t *testi assert.Contains(t, resp, "global_config") } +func TestWidgetHandler_ChatwootConversationQueryTokenReusesSession(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, inbox := seedWidgetHandlerData(t, db) + + wConfig := httptest.NewRecorder() + reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) + router.ServeHTTP(wConfig, reqConfig) + require.Equal(t, http.StatusOK, wConfig.Code) + var configResp map[string]interface{} + require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp)) + authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string) + + messageBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "Popout session message"}}) + wMessage := httptest.NewRecorder() + reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages?cw_conversation="+authToken, bytes.NewReader(messageBody)) + reqMessage.Header.Set("Content-Type", "application/json") + router.ServeHTTP(wMessage, reqMessage) + require.Equal(t, http.StatusOK, wMessage.Code, wMessage.Body.String()) + + var messageResp map[string]interface{} + require.NoError(t, json.Unmarshal(wMessage.Body.Bytes(), &messageResp)) + assert.Equal(t, "Popout session message", messageResp["content"]) + assert.Equal(t, "incoming", messageResp["message_type"]) + assertWidgetMessageFixtureShape(t, messageResp) + + wLatest := httptest.NewRecorder() + reqLatest, _ := http.NewRequest("GET", "/api/v1/widget/messages?cw_conversation="+authToken, nil) + router.ServeHTTP(wLatest, reqLatest) + require.Equal(t, http.StatusOK, wLatest.Code, wLatest.Body.String()) + var latestResp map[string]interface{} + require.NoError(t, json.Unmarshal(wLatest.Body.Bytes(), &latestResp)) + payload := latestResp["payload"].([]interface{}) + require.Len(t, payload, 1) + assert.Equal(t, "Popout session message", payload[0].(map[string]interface{})["content"]) + + var conversation model.Conversation + require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&conversation).Error) + assert.Equal(t, string(model.ConversationStatusOpen), conversation.Status) +} + +func TestWidgetHandler_ChatwootConfigCwConversationReusesContactInbox(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, inbox := seedWidgetHandlerData(t, db) + + wConfig := httptest.NewRecorder() + reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) + router.ServeHTTP(wConfig, reqConfig) + require.Equal(t, http.StatusOK, wConfig.Code) + var configResp map[string]interface{} + require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp)) + authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string) + contactID := uint(configResp["contact"].(map[string]interface{})["id"].(float64)) + + messageBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "Popout boot history"}}) + wMessage := httptest.NewRecorder() + reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages?cw_conversation="+authToken, bytes.NewReader(messageBody)) + reqMessage.Header.Set("Content-Type", "application/json") + router.ServeHTTP(wMessage, reqMessage) + require.Equal(t, http.StatusOK, wMessage.Code, wMessage.Body.String()) + + var beforeContacts int64 + require.NoError(t, db.Model(&model.Contact{}).Where("account_id = ?", inbox.AccountID).Count(&beforeContacts).Error) + wPopoutConfig := httptest.NewRecorder() + reqPopoutConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123&cw_conversation="+authToken, nil) + router.ServeHTTP(wPopoutConfig, reqPopoutConfig) + require.Equal(t, http.StatusOK, wPopoutConfig.Code, wPopoutConfig.Body.String()) + var popoutConfigResp map[string]interface{} + require.NoError(t, json.Unmarshal(wPopoutConfig.Body.Bytes(), &popoutConfigResp)) + popoutContact := popoutConfigResp["contact"].(map[string]interface{}) + assert.Equal(t, float64(contactID), popoutContact["id"]) + assert.Equal(t, authToken, popoutContact["pubsub_token"]) + assertWidgetConfigContactFixtureShape(t, popoutContact) + + var afterContacts int64 + require.NoError(t, db.Model(&model.Contact{}).Where("account_id = ?", inbox.AccountID).Count(&afterContacts).Error) + assert.Equal(t, beforeContacts, afterContacts) + + wLatest := httptest.NewRecorder() + reqLatest, _ := http.NewRequest("GET", "/api/v1/widget/messages?cw_conversation="+authToken, nil) + router.ServeHTTP(wLatest, reqLatest) + require.Equal(t, http.StatusOK, wLatest.Code, wLatest.Body.String()) + var latestResp map[string]interface{} + require.NoError(t, json.Unmarshal(wLatest.Body.Bytes(), &latestResp)) + payload := latestResp["payload"].([]interface{}) + require.Len(t, payload, 1) + assert.Equal(t, "Popout boot history", payload[0].(map[string]interface{})["content"]) +} + +func TestWidgetHandler_ChatwootConversationCookieReusesSession(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, inbox := seedWidgetHandlerData(t, db) + + wConfig := httptest.NewRecorder() + reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) + router.ServeHTTP(wConfig, reqConfig) + require.Equal(t, http.StatusOK, wConfig.Code) + var configResp map[string]interface{} + require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp)) + authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string) + contactID := uint(configResp["contact"].(map[string]interface{})["id"].(float64)) + + messageBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "Cookie persisted history"}}) + wMessage := httptest.NewRecorder() + reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(messageBody)) + reqMessage.Header.Set("Content-Type", "application/json") + reqMessage.AddCookie(&http.Cookie{Name: "cw_conversation", Value: authToken}) + router.ServeHTTP(wMessage, reqMessage) + require.Equal(t, http.StatusOK, wMessage.Code, wMessage.Body.String()) + + var beforeContacts int64 + require.NoError(t, db.Model(&model.Contact{}).Where("account_id = ?", inbox.AccountID).Count(&beforeContacts).Error) + wCookieConfig := httptest.NewRecorder() + reqCookieConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) + reqCookieConfig.AddCookie(&http.Cookie{Name: "cw_conversation", Value: authToken}) + router.ServeHTTP(wCookieConfig, reqCookieConfig) + require.Equal(t, http.StatusOK, wCookieConfig.Code, wCookieConfig.Body.String()) + var cookieConfigResp map[string]interface{} + require.NoError(t, json.Unmarshal(wCookieConfig.Body.Bytes(), &cookieConfigResp)) + cookieContact := cookieConfigResp["contact"].(map[string]interface{}) + assert.Equal(t, float64(contactID), cookieContact["id"]) + assert.Equal(t, authToken, cookieContact["pubsub_token"]) + + var afterContacts int64 + require.NoError(t, db.Model(&model.Contact{}).Where("account_id = ?", inbox.AccountID).Count(&afterContacts).Error) + assert.Equal(t, beforeContacts, afterContacts) + + wLatest := httptest.NewRecorder() + reqLatest, _ := http.NewRequest("GET", "/api/v1/widget/messages", nil) + reqLatest.AddCookie(&http.Cookie{Name: "cw_conversation", Value: authToken}) + router.ServeHTTP(wLatest, reqLatest) + require.Equal(t, http.StatusOK, wLatest.Code, wLatest.Body.String()) + var latestResp map[string]interface{} + require.NoError(t, json.Unmarshal(wLatest.Body.Bytes(), &latestResp)) + payload := latestResp["payload"].([]interface{}) + require.Len(t, payload, 1) + assert.Equal(t, "Cookie persisted history", payload[0].(map[string]interface{})["content"]) +} + func TestWidgetHandler_ChatwootMessageAppliesAttrsAndLabelsToNewConversation(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) account, _ := seedWidgetHandlerData(t, db) @@ -527,6 +727,7 @@ func TestWidgetHandler_ChatwootMessages_AuthTokenAndNestedPayload(t *testing.T) var messageResp map[string]interface{} require.NoError(t, json.Unmarshal(wMessage.Body.Bytes(), &messageResp)) + assertWidgetMessageFixtureShape(t, messageResp) assert.Nil(t, messageResp["message"]) assert.Equal(t, "Hello from Chatwoot widget", messageResp["content"]) assert.NotEmpty(t, messageResp["conversation_id"]) @@ -542,6 +743,7 @@ func TestWidgetHandler_ChatwootMessages_AuthTokenAndNestedPayload(t *testing.T) payload := indexResp["payload"].([]interface{}) require.Len(t, payload, 1) firstMessage := payload[0].(map[string]interface{}) + assertWidgetMessageFixtureShape(t, firstMessage) assert.Equal(t, "Hello from Chatwoot widget", firstMessage["content"]) wContact := httptest.NewRecorder() @@ -549,6 +751,45 @@ func TestWidgetHandler_ChatwootMessages_AuthTokenAndNestedPayload(t *testing.T) reqContact.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wContact, reqContact) require.Equal(t, http.StatusOK, wContact.Code) + var contactResp map[string]interface{} + require.NoError(t, json.Unmarshal(wContact.Body.Bytes(), &contactResp)) + assertWidgetContactFixtureShape(t, contactResp) +} + +func TestWidgetHandler_ChatwootMessagePublishesWidgetRealtimePayload(t *testing.T) { + db, router, handler := setupWidgetHandlerTest(t) + _, _ = seedWidgetHandlerData(t, db) + publisher := &recordingWidgetEventPublisher{} + handler.WithEventPublisher(publisher) + + wConfig := httptest.NewRecorder() + reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) + router.ServeHTTP(wConfig, reqConfig) + require.Equal(t, http.StatusOK, wConfig.Code) + + var configResp map[string]interface{} + require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp)) + authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string) + + body, _ := json.Marshal(map[string]interface{}{ + "message": map[string]interface{}{"content": "Realtime widget message"}, + }) + wMessage := httptest.NewRecorder() + reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(body)) + reqMessage.Header.Set("Content-Type", "application/json") + reqMessage.Header.Set("X-Auth-Token", authToken) + router.ServeHTTP(wMessage, reqMessage) + require.Equal(t, http.StatusOK, wMessage.Code) + + assert.Equal(t, uint(1), publisher.accountID) + assert.Equal(t, authToken, publisher.pubsubToken) + assert.Equal(t, ws.EventMessageCreated, publisher.eventType) + payload, ok := publisher.payload.(gin.H) + require.True(t, ok, "expected widget realtime payload to use Chatwoot widget message shape") + assert.Equal(t, "Realtime widget message", payload["content"]) + assert.Equal(t, uint(1), payload["account_id"]) + assert.NotEmpty(t, payload["conversation_id"]) + assertWidgetMessageFixtureShape(t, payload) } func TestWidgetHandler_ChatwootMessagesIndexFiltersInternalMessages(t *testing.T) { @@ -717,10 +958,12 @@ func TestWidgetHandler_ChatwootMessageDirectUploadAttachment(t *testing.T) { var messageResp map[string]interface{} require.NoError(t, json.Unmarshal(wMessage.Body.Bytes(), &messageResp)) + assertWidgetMessageFixtureShape(t, messageResp) assert.Empty(t, messageResp["content"]) attachments := messageResp["attachments"].([]interface{}) require.Len(t, attachments, 1) attachmentPayload := attachments[0].(map[string]interface{}) + assertWidgetAttachmentFixtureShape(t, attachmentPayload) assert.Equal(t, "/uploads/widget_direct/signed-widget-upload-1.png", attachmentPayload["data_url"]) assert.Equal(t, "image", attachmentPayload["file_type"]) assert.Equal(t, float64(account.ID), attachmentPayload["account_id"]) @@ -743,9 +986,11 @@ func TestWidgetHandler_ChatwootMessageDirectUploadAttachment(t *testing.T) { payload := indexResp["payload"].([]interface{}) require.Len(t, payload, 1) indexedMessage := payload[0].(map[string]interface{}) + assertWidgetMessageFixtureShape(t, indexedMessage) indexedAttachments := indexedMessage["attachments"].([]interface{}) require.Len(t, indexedAttachments, 1) indexedAttachment := indexedAttachments[0].(map[string]interface{}) + assertWidgetAttachmentFixtureShape(t, indexedAttachment) assert.Equal(t, "/uploads/widget_direct/signed-widget-upload-1.png", indexedAttachment["data_url"]) assert.Equal(t, float64(account.ID), indexedAttachment["account_id"]) assert.NotContains(t, indexedAttachment, "created_at") @@ -1463,6 +1708,7 @@ func TestWidgetHandler_ChatwootCampaigns_Success(t *testing.T) { var resp []map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.Len(t, resp, 1) + assertWidgetCampaignFixtureShape(t, resp[0]) assert.Equal(t, float64(42), resp[0]["id"]) assert.Equal(t, "Welcome to support", resp[0]["message"]) rules := resp[0]["trigger_rules"].(map[string]interface{}) @@ -1530,6 +1776,14 @@ func TestWidgetHandler_ChatwootEventsAndLabels(t *testing.T) { router.ServeHTTP(wEvent, reqEvent) require.Equal(t, http.StatusNoContent, wEvent.Code) + wCookieEvent := httptest.NewRecorder() + cookieEventBody, _ := json.Marshal(map[string]interface{}{"name": "webwidget.triggered", "event_info": map[string]interface{}{"source": "cookie"}}) + reqCookieEvent, _ := http.NewRequest("POST", "/api/v1/widget/events?website_token=handler_ws_token_123", bytes.NewReader(cookieEventBody)) + reqCookieEvent.Header.Set("Content-Type", "application/json") + reqCookieEvent.AddCookie(&http.Cookie{Name: "cw_conversation", Value: authToken}) + router.ServeHTTP(wCookieEvent, reqCookieEvent) + require.Equal(t, http.StatusNoContent, wCookieEvent.Code) + wAdd := httptest.NewRecorder() labelBody, _ := json.Marshal(map[string]interface{}{"label": "vip"}) reqAdd, _ := http.NewRequest("POST", "/api/v1/widget/labels", bytes.NewReader(labelBody)) @@ -1549,6 +1803,25 @@ func TestWidgetHandler_ChatwootEventsAndLabels(t *testing.T) { require.NoError(t, db.First(&conversation, conversation.ID).Error) assert.Empty(t, conversation.Labels) + + wCookieAdd := httptest.NewRecorder() + reqCookieAdd, _ := http.NewRequest("POST", "/api/v1/widget/labels", bytes.NewReader(labelBody)) + reqCookieAdd.Header.Set("Content-Type", "application/json") + reqCookieAdd.AddCookie(&http.Cookie{Name: "cw_conversation", Value: authToken}) + router.ServeHTTP(wCookieAdd, reqCookieAdd) + require.Equal(t, http.StatusNoContent, wCookieAdd.Code) + + require.NoError(t, db.First(&conversation, conversation.ID).Error) + assert.Equal(t, "vip", conversation.Labels) + + wCookieRemove := httptest.NewRecorder() + reqCookieRemove, _ := http.NewRequest("DELETE", "/api/v1/widget/labels/vip", nil) + reqCookieRemove.AddCookie(&http.Cookie{Name: "cw_conversation", Value: authToken}) + router.ServeHTTP(wCookieRemove, reqCookieRemove) + require.Equal(t, http.StatusNoContent, wCookieRemove.Code) + + require.NoError(t, db.First(&conversation, conversation.ID).Error) + assert.Empty(t, conversation.Labels) } func TestWidgetHandler_ChatwootEvents_InvalidWebsiteTokenReturnsNotFound(t *testing.T) { @@ -2130,6 +2403,25 @@ func TestWidgetHandler_PublicAPIInboxContactConversationMessageFlow(t *testing.T assert.Equal(t, "Public Visitor", contactResp["name"]) assert.Equal(t, "visitor@public.test", contactResp["email"]) assert.NotEmpty(t, contactResp["pubsub_token"]) + assertPublicContactFixtureShape(t, contactResp) + + contactUpdateBody := map[string]any{ + "name": "Updated Public Visitor", + "email": "Updated@Public.test", + "phone_number": "+15550002222", + } + contactUpdateJSON, _ := json.Marshal(contactUpdateBody) + w = httptest.NewRecorder() + req, _ = http.NewRequest("PATCH", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1", bytes.NewReader(contactUpdateJSON)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var contactUpdateResp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &contactUpdateResp)) + assertPublicContactFixtureShape(t, contactUpdateResp) + assert.Equal(t, "Updated Public Visitor", contactUpdateResp["name"]) + assert.Equal(t, "updated@public.test", contactUpdateResp["email"]) w = httptest.NewRecorder() req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations", bytes.NewReader([]byte(`{"custom_attributes":{"topic":"sales"}}`))) @@ -2141,6 +2433,7 @@ func TestWidgetHandler_PublicAPIInboxContactConversationMessageFlow(t *testing.T require.NoError(t, json.Unmarshal(w.Body.Bytes(), &conversationResp)) conversationID := strconv.FormatUint(uint64(conversationResp["id"].(float64)), 10) assert.Equal(t, "open", conversationResp["status"]) + assertPublicConversationFixtureShape(t, conversationResp) messageBody := map[string]any{"content": "Hello from public API", "echo_id": "echo-1"} messageJSON, _ := json.Marshal(messageBody) @@ -2155,6 +2448,7 @@ func TestWidgetHandler_PublicAPIInboxContactConversationMessageFlow(t *testing.T messageID := strconv.FormatUint(uint64(messageResp["id"].(float64)), 10) assert.Equal(t, "Hello from public API", messageResp["content"]) assert.Equal(t, "incoming", messageResp["message_type"]) + assertPublicMessageFixtureShape(t, messageResp) w = httptest.NewRecorder() req, _ = http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/messages", nil) @@ -2165,6 +2459,7 @@ func TestWidgetHandler_PublicAPIInboxContactConversationMessageFlow(t *testing.T require.NoError(t, json.Unmarshal(w.Body.Bytes(), &messagesResp)) require.Len(t, messagesResp, 1) assert.Equal(t, "Hello from public API", messagesResp[0]["content"]) + assertPublicMessageFixtureShape(t, messagesResp[0]) updateBody := map[string]any{"submitted_values": []map[string]any{{"name": "email", "value": "visitor@public.test"}}} updateJSON, _ := json.Marshal(updateBody) @@ -2175,6 +2470,7 @@ func TestWidgetHandler_PublicAPIInboxContactConversationMessageFlow(t *testing.T assert.Equal(t, http.StatusOK, w.Code) require.NoError(t, json.Unmarshal(w.Body.Bytes(), &messageResp)) + assertPublicMessageFixtureShape(t, messageResp) attrs := messageResp["content_attributes"].(map[string]any) assert.NotEmpty(t, attrs["submitted_values"]) @@ -2182,12 +2478,27 @@ func TestWidgetHandler_PublicAPIInboxContactConversationMessageFlow(t *testing.T req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/toggle_status", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &conversationResp)) + assertPublicConversationFixtureShape(t, conversationResp) + assert.Equal(t, "resolved", conversationResp["status"]) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID, nil) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &conversationResp)) + assertPublicConversationFixtureShape(t, conversationResp) + assert.Equal(t, "resolved", conversationResp["status"]) w = httptest.NewRecorder() req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/update_last_seen", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) + var conversation model.Conversation + require.NoError(t, db.Where("inbox_id = ? AND contact_id = ?", inbox.ID, contactResp["id"]).First(&conversation).Error) + require.NotNil(t, conversation.ContactLastSeenAt) + w = httptest.NewRecorder() req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/toggle_typing", bytes.NewReader([]byte(`{"typing_status":"on"}`))) req.Header.Set("Content-Type", "application/json") @@ -2521,3 +2832,68 @@ func TestWidgetHandler_PublicAPIMessageUpdate_CsatLocked(t *testing.T) { require.Equal(t, http.StatusUnprocessableEntity, w.Code) assert.Contains(t, w.Body.String(), "You cannot update the CSAT survey after 14 days") } + +func assertWidgetConfigFixtureShape(t *testing.T, config map[string]interface{}) { + t.Helper() + for _, key := range []string{"auth_token", "website_token", "website_name", "welcome_tagline", "welcome_title", "widget_color"} { + assert.Contains(t, config, key, "widget config should expose %s", key) + } +} + +func assertWidgetContactFixtureShape(t *testing.T, contact map[string]interface{}) { + t.Helper() + for _, key := range []string{"has_email", "has_name", "has_phone_number", "id", "identifier"} { + assert.Contains(t, contact, key, "widget contact should expose %s", key) + } +} + +func assertWidgetConfigContactFixtureShape(t *testing.T, contact map[string]interface{}) { + t.Helper() + for _, key := range []string{"id", "identifier", "pubsub_token"} { + assert.Contains(t, contact, key, "widget config contact should expose %s", key) + } +} + +func assertWidgetMessageFixtureShape(t *testing.T, message map[string]interface{}) { + t.Helper() + for _, key := range []string{"content", "content_attributes", "content_type", "conversation_id", "created_at", "id", "inbox_id", "message_type", "private", "source_id"} { + assert.Contains(t, message, key, "widget message should expose %s", key) + } +} + +func assertWidgetAttachmentFixtureShape(t *testing.T, attachment map[string]interface{}) { + t.Helper() + for _, key := range []string{"account_id", "data_url", "extension", "file_size", "file_type", "height", "id", "message_id", "thumb_url", "width"} { + assert.Contains(t, attachment, key, "widget attachment should expose %s", key) + } + assert.NotContains(t, attachment, "created_at") + assert.NotContains(t, attachment, "updated_at") +} + +func assertWidgetCampaignFixtureShape(t *testing.T, campaign map[string]interface{}) { + t.Helper() + for _, key := range []string{"id", "message", "sender", "trigger_only_during_business_hours", "trigger_rules"} { + assert.Contains(t, campaign, key, "widget campaign should expose %s", key) + } +} + +func assertPublicContactFixtureShape(t *testing.T, contact map[string]any) { + t.Helper() + for _, key := range []string{"source_id", "pubsub_token", "id", "name", "email", "phone_number"} { + assert.Contains(t, contact, key, "public contact should expose %s", key) + } +} + +func assertPublicConversationFixtureShape(t *testing.T, conversation map[string]any) { + t.Helper() + for _, key := range []string{"id", "uuid", "inbox_id", "contact_last_seen_at", "status", "agent_last_seen_at", "contact", "messages"} { + assert.Contains(t, conversation, key, "public conversation should expose %s", key) + } +} + +func assertPublicMessageFixtureShape(t *testing.T, message map[string]any) { + t.Helper() + for _, key := range []string{"id", "content", "message_type", "content_type", "content_attributes", "created_at", "conversation_id"} { + assert.Contains(t, message, key, "public message should expose %s", key) + } +} diff --git a/internal/handler/ws/protocol.go b/internal/handler/ws/protocol.go index 3fbf1ee1..ae0bfca2 100644 --- a/internal/handler/ws/protocol.go +++ b/internal/handler/ws/protocol.go @@ -29,7 +29,7 @@ type ServerMessageType string const ( // ServerEvent pushes a real-time event to subscribed clients - ServerEvent ServerMessageType = "event" + ServerEvent ServerMessageType = "event" // ServerConfirmSubscribe acknowledges a successful subscription ServerConfirmSubscribe ServerMessageType = "confirm_subscribe" // ServerConfirmUnsubscribe acknowledges a successful unsubscribe @@ -50,15 +50,15 @@ const ( // Mirrors Chatwoot ActionCable's command structure. type CommandFrame struct { Command CommandType `json:"command"` - Identifier string `json:"identifier"` // JSON-encoded ChannelIdentifier + Identifier string `json:"identifier"` // JSON-encoded ChannelIdentifier Data string `json:"data,omitempty"` // optional action data } // ChannelIdentifier describes which "channel" (room) the client wants to subscribe to. // Serialized as JSON string in the `identifier` field, matching ActionCable convention. type ChannelIdentifier struct { - Channel string `json:"channel"` // "AccountChannel" or "ConversationChannel" - AccountID uint `json:"account_id"` // required for both channels + Channel string `json:"channel"` // "AccountChannel" or "ConversationChannel" + AccountID uint `json:"account_id"` // required for both channels ConversationID uint `json:"conversation_id,omitempty"` // required for ConversationChannel } @@ -73,9 +73,9 @@ const ( // EventFrame pushes a real-time event payload to the client. type EventFrame struct { Type ServerMessageType `json:"type"` - Event string `json:"event,omitempty"` // e.g. "message.created" - Payload interface{} `json:"payload,omitempty"` // event data - Identifier string `json:"identifier,omitempty"` // channel identifier + Event string `json:"event,omitempty"` // e.g. "message.created" + Payload interface{} `json:"payload,omitempty"` // event data + Identifier string `json:"identifier,omitempty"` // channel identifier } // ConfirmFrame acknowledges a subscribe/unsubscribe command. @@ -104,9 +104,9 @@ type WelcomeFrame struct { // DisconnectFrame is sent before closing a connection. type DisconnectFrame struct { - Type ServerMessageType `json:"type"` - Reason string `json:"reason"` - Reconnect bool `json:"reconnect"` + Type ServerMessageType `json:"type"` + Reason string `json:"reason"` + Reconnect bool `json:"reconnect"` } // --- Real-time Event Type Constants --- @@ -119,11 +119,11 @@ const ( EventMessageDeleted = "message.deleted" // Conversation events - EventConversationCreated = "conversation.created" - EventConversationUpdated = "conversation.updated" - EventConversationResolved = "conversation.resolved" - EventConversationOpened = "conversation.opened" - EventConversationAssigned = "conversation.assigned" + EventConversationCreated = "conversation.created" + EventConversationUpdated = "conversation.updated" + EventConversationResolved = "conversation.resolved" + EventConversationOpened = "conversation.opened" + EventConversationAssigned = "conversation.assigned" EventConversationUnassigned = "conversation.unassigned" // Contact events @@ -134,8 +134,8 @@ const ( // Agent/typing events EventAgentTypingOn = "agent.typing_on" EventAgentTypingOff = "agent.typing_off" - EventAgentOnline = "agent.online" - EventAgentOffline = "agent.offline" + EventAgentOnline = "agent.online" + EventAgentOffline = "agent.offline" // Inbox events EventInboxCreated = "inbox.created" @@ -147,6 +147,11 @@ const ( // P4 M8 — Notification+Webhook event types EventNotificationCreated = "notification.created" + EventNotificationUpdated = "notification.updated" + EventNotificationDeleted = "notification.deleted" + + // Account cache event types + EventAccountCacheInvalidated = "account.cache_invalidated" ) // --- Ping/pong Configuration --- @@ -154,4 +159,4 @@ const ( const ( // PingInterval is how often the server sends ping frames to detect dead connections. PingInterval = 30 // seconds -) \ No newline at end of file +) diff --git a/internal/handler/ws/subscriber.go b/internal/handler/ws/subscriber.go index d6a2bdc4..bed53c51 100644 --- a/internal/handler/ws/subscriber.go +++ b/internal/handler/ws/subscriber.go @@ -7,8 +7,8 @@ import ( "strings" "github.com/ThreeDotsLabs/watermill" - "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill-redisstream/pkg/redisstream" + "github.com/ThreeDotsLabs/watermill/message" "github.com/redis/go-redis/v9" "github.com/gochat/gochat/pkg/logger" @@ -19,17 +19,18 @@ import ( // and forwards the events to the appropriate WebSocket rooms via the Hub. // // Architecture mapping: -// Chatwoot ActionCable broadcasts → Watermill subscriber → Hub.SendToAccount/SendToConversationJSON -// Each GoChat instance runs its own subscriber, so events are delivered to -// locally-connected WebSocket clients. Multi-instance delivery relies on Redis -// PubSub (each instance receives the event and pushes to its own clients). +// +// Chatwoot ActionCable broadcasts → Watermill subscriber → Hub.SendToAccount/SendToConversationJSON +// Each GoChat instance runs its own subscriber, so events are delivered to +// locally-connected WebSocket clients. Multi-instance delivery relies on Redis +// PubSub (each instance receives the event and pushes to its own clients). // // Reference: P2E §3 — Real-time communication via Redis Pub/Sub type Subscriber struct { - hub *Hub - subscriber *redisstream.Subscriber - router *message.Router - redisClient redis.UniversalClient + hub *Hub + subscriber *redisstream.Subscriber + router *message.Router + redisClient redis.UniversalClient } // NewSubscriber creates a PubSub-to-WebSocket bridge subscriber. @@ -172,6 +173,28 @@ func (s *Subscriber) registerHandlers() { s.subscriber, s.forwardToAccount(EventNotificationCreated), ) + + s.router.AddNoPublisherHandler( + "ws-notification-updated-handler", + "gochat.notification.updated", + s.subscriber, + s.forwardToAccount(EventNotificationUpdated), + ) + + s.router.AddNoPublisherHandler( + "ws-notification-deleted-handler", + "gochat.notification.deleted", + s.subscriber, + s.forwardToAccount(EventNotificationDeleted), + ) + + // --- Account cache events → Account room --- + s.router.AddNoPublisherHandler( + "ws-account-cache-invalidated-handler", + "gochat.account.cache_invalidated", + s.subscriber, + s.forwardToAccount(EventAccountCacheInvalidated), + ) } // forwardToAccountAndConversation creates a handler that pushes an event to both @@ -356,4 +379,4 @@ func (s *Subscriber) Close() error { // Running returns whether the router is currently running. func (s *Subscriber) Running() bool { return s.router.IsRunning() -} \ No newline at end of file +} diff --git a/internal/middleware/account_scope.go b/internal/middleware/account_scope.go index f68b0c9f..2424ddfa 100644 --- a/internal/middleware/account_scope.go +++ b/internal/middleware/account_scope.go @@ -39,9 +39,14 @@ func AccountScope() gin.HandlerFunc { return } - // Step 2: Determine account_id - // Priority: X-Account-ID header > JWT claims account_id - accountID := getAccountID(c) + // Step 2: Determine account_id. + // Account-scoped Chatwoot routes carry :account_id in the URL. The token/header + // account context must match that URL account instead of silently allowing a + // token scoped to one account to read another account's route. + accountID, ok := resolveScopedAccountID(c) + if !ok { + return + } if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "Account ID required — provide via X-Account-ID header or JWT claims") @@ -107,7 +112,10 @@ func AccountScopeWithService(lookup RBACLookup) gin.HandlerFunc { return } - accountID := getAccountID(c) + accountID, ok := resolveScopedAccountID(c) + if !ok { + return + } if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "Account ID required — provide via X-Account-ID header or JWT claims") @@ -154,6 +162,41 @@ func AccountScopeWithService(lookup RBACLookup) gin.HandlerFunc { } } +func resolveScopedAccountID(c *gin.Context) (uint, bool) { + contextAccountID := getAccountID(c) + routeAccountID, hasRouteAccountID, routeOK := routeAccountID(c) + if !routeOK { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") + return 0, false + } + if !hasRouteAccountID { + return contextAccountID, true + } + if contextAccountID == 0 { + return routeAccountID, true + } + if contextAccountID != routeAccountID { + response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, "User does not belong to this account") + return 0, false + } + return routeAccountID, true +} + +func routeAccountID(c *gin.Context) (uint, bool, bool) { + for _, param := range []string{"account_id", "id"} { + raw := c.Param(param) + if raw == "" { + continue + } + id, err := strconv.ParseUint(raw, 10, 32) + if err != nil || id == 0 { + return 0, true, false + } + return uint(id), true, true + } + return 0, false, true +} + // getAccountID extracts account ID from the request. // Priority: X-Account-ID header > JWT claims account_id func getAccountID(c *gin.Context) uint { diff --git a/internal/middleware/account_scope_test.go b/internal/middleware/account_scope_test.go index d4b8b19a..88fa6394 100644 --- a/internal/middleware/account_scope_test.go +++ b/internal/middleware/account_scope_test.go @@ -102,4 +102,36 @@ func TestAccountScope_InvalidHeaderAccountID(t *testing.T) { req.Header.Set("X-Account-ID", "abc") r.ServeHTTP(w, req) assert.Equal(t, 400, w.Code) -} \ No newline at end of file +} + +func TestAccountScope_RouteAccountMustMatchContext(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { c.Set("user_id", uint(1)); c.Set("account_id", uint(2)); c.Next() }) + r.Use(AccountScope()) + r.GET("/api/v2/accounts/:account_id/live_reports/conversation_metrics", func(c *gin.Context) { + accountID, _ := c.Get("account_id") + c.JSON(200, gin.H{"account_id": accountID}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v2/accounts/3/live_reports/conversation_metrics", nil) + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestAccountScope_RouteAccountMatchesContext(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { c.Set("user_id", uint(1)); c.Set("account_id", uint(2)); c.Next() }) + r.Use(AccountScope()) + r.GET("/api/v2/accounts/:account_id/live_reports/conversation_metrics", func(c *gin.Context) { + accountID, _ := c.Get("account_id") + c.JSON(200, gin.H{"account_id": accountID}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v2/accounts/2/live_reports/conversation_metrics", nil) + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index c6ee8eaa..6990f075 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -32,9 +32,12 @@ func AuthMiddleware(cfg *config.JWTConfig) gin.HandlerFunc { func AuthMiddlewareWithService(jwtSvc *auth.JWTService) gin.HandlerFunc { return func(c *gin.Context) { authHeader := c.GetHeader("Authorization") - if authHeader != "" { + chatwootAccessToken := strings.TrimSpace(c.GetHeader("access-token")) + if authHeader != "" || chatwootAccessToken != "" { tokenString := strings.TrimPrefix(authHeader, "Bearer ") - if tokenString == authHeader { + if authHeader == "" { + tokenString = chatwootAccessToken + } else if tokenString == authHeader { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "bearer token required"}) return } @@ -105,4 +108,4 @@ func GenerateToken(cfg *config.JWTConfig, userID uint, accountID uint, role stri token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString([]byte(cfg.Secret)) -} \ No newline at end of file +} diff --git a/internal/middleware/auth_test.go b/internal/middleware/auth_test.go index 484e2dd9..56cd3143 100644 --- a/internal/middleware/auth_test.go +++ b/internal/middleware/auth_test.go @@ -98,6 +98,26 @@ func TestAuthMiddleware_ValidToken(t *testing.T) { assert.Equal(t, 200, w.Code) } +func TestAuthMiddleware_ChatwootAccessTokenHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + cfg := makeJWTConfig() + r := gin.New() + r.Use(AuthMiddleware(cfg)) + r.GET("/test", func(c *gin.Context) { + userID, _ := c.Get("user_id") + accountID, _ := c.Get("account_id") + c.JSON(200, gin.H{"user_id": userID, "account_id": accountID}) + }) + + token := makeValidAccessToken(cfg) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("access-token", token) + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) +} + func TestAuthMiddleware_FallbackHeaders(t *testing.T) { gin.SetMode(gin.TestMode) cfg := makeJWTConfig() @@ -155,4 +175,4 @@ func TestGenerateToken(t *testing.T) { tokenString, err := GenerateToken(cfg, 1, 2, "agent") assert.NoError(t, err) assert.NotEmpty(t, tokenString) -} \ No newline at end of file +} diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go index ba14f7cc..ab2622df 100644 --- a/internal/middleware/cors.go +++ b/internal/middleware/cors.go @@ -18,14 +18,14 @@ type CORSConfig struct { AllowedHeaders []string ExposeHeaders []string AllowCredentials bool - MaxAge int // seconds + MaxAge int // seconds DevMode bool // when true and AllowedOrigins is empty, fall back to Allow-Origin: * } // Default CORS values for production. var defaultCORSMethods = []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"} -var defaultCORSHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Account-ID"} -var defaultCORSExposeHeaders = []string{"Content-Length"} +var defaultCORSHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Account-ID", "access-token", "client", "uid", "token-type", "expiry"} +var defaultCORSExposeHeaders = []string{"Content-Length", "access-token", "client", "uid", "token-type", "expiry"} var defaultCORSMaxAge = 86400 // 24 hours // CORS adds Cross-Origin Resource Sharing headers. @@ -164,4 +164,4 @@ func CORSConfigFromAppConfig(cfg *config.Config) CORSConfig { MaxAge: cfg.Server.CORS.MaxAge, DevMode: devMode, } -} \ No newline at end of file +} diff --git a/internal/middleware/cors_test.go b/internal/middleware/cors_test.go index fdf55d06..37e95a69 100644 --- a/internal/middleware/cors_test.go +++ b/internal/middleware/cors_test.go @@ -32,10 +32,54 @@ func TestCORS_DevMode_AllOrigins(t *testing.T) { assert.Contains(t, w.Header().Get("Access-Control-Allow-Methods"), "GET") assert.Contains(t, w.Header().Get("Access-Control-Allow-Methods"), "POST") assert.Contains(t, w.Header().Get("Access-Control-Allow-Headers"), "Authorization") - assert.Equal(t, "Content-Length", w.Header().Get("Access-Control-Expose-Headers")) + assert.Contains(t, w.Header().Get("Access-Control-Allow-Headers"), "access-token") + assert.Contains(t, w.Header().Get("Access-Control-Allow-Headers"), "client") + assert.Contains(t, w.Header().Get("Access-Control-Allow-Headers"), "uid") + assert.Contains(t, w.Header().Get("Access-Control-Allow-Headers"), "token-type") + assert.Contains(t, w.Header().Get("Access-Control-Expose-Headers"), "Content-Length") + assert.Contains(t, w.Header().Get("Access-Control-Expose-Headers"), "access-token") + assert.Contains(t, w.Header().Get("Access-Control-Expose-Headers"), "client") + assert.Contains(t, w.Header().Get("Access-Control-Expose-Headers"), "uid") + assert.Contains(t, w.Header().Get("Access-Control-Expose-Headers"), "token-type") + assert.Contains(t, w.Header().Get("Access-Control-Expose-Headers"), "expiry") assert.Equal(t, "86400", w.Header().Get("Access-Control-Max-Age")) } +func TestCORS_DefaultAllowedHeadersIncludeChatwootAuthTokens(t *testing.T) { + cfg := CORSConfig{DevMode: true} + router := gin.New() + router.Use(CORS(cfg)) + router.GET("/auth/validate_token", func(c *gin.Context) { c.Status(200) }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("OPTIONS", "/auth/validate_token", nil) + req.Header.Set("Origin", "http://localhost:3037") + req.Header.Set("Access-Control-Request-Headers", "access-token, client, uid, token-type") + router.ServeHTTP(w, req) + + allowedHeaders := w.Header().Get("Access-Control-Allow-Headers") + for _, header := range []string{"access-token", "client", "uid", "token-type", "expiry"} { + assert.Contains(t, allowedHeaders, header) + } +} + +func TestCORS_DefaultExposeHeadersIncludeChatwootAuthTokens(t *testing.T) { + cfg := CORSConfig{DevMode: true} + router := gin.New() + router.Use(CORS(cfg)) + router.POST("/auth/sign_in", func(c *gin.Context) { c.Status(200) }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/auth/sign_in", nil) + req.Header.Set("Origin", "http://localhost:3037") + router.ServeHTTP(w, req) + + exposedHeaders := w.Header().Get("Access-Control-Expose-Headers") + for _, header := range []string{"access-token", "client", "uid", "token-type", "expiry"} { + assert.Contains(t, exposedHeaders, header) + } +} + func TestCORS_DevMode_WithWhitelist(t *testing.T) { cfg := CORSConfig{ AllowedOrigins: []string{"https://app.example.com"}, @@ -314,4 +358,4 @@ func TestCORSConfigFromAppConfig_EmptyCORS(t *testing.T) { assert.False(t, mwCfg.DevMode) assert.Empty(t, mwCfg.AllowedOrigins) assert.Empty(t, mwCfg.AllowedMethods) // defaults applied in CORS() middleware, not here -} \ No newline at end of file +} diff --git a/internal/middleware/csrf_test.go b/internal/middleware/csrf_test.go new file mode 100644 index 00000000..be9ca87c --- /dev/null +++ b/internal/middleware/csrf_test.go @@ -0,0 +1,56 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestCSRFSkipsChatwootAuthRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(CSRF(CSRFConfig{ + Enabled: true, + Secret: "test-secret", + CookieName: "_gochat_csrf", + HeaderName: "X-CSRF-Token", + TokenLength: 32, + SafeMethods: []string{"GET", "HEAD", "OPTIONS"}, + SkipPaths: []string{"/auth/"}, + CookiePath: "/", + CookieSameSite: "Lax", + })) + router.POST("/auth/sign_in", func(c *gin.Context) { c.Status(http.StatusOK) }) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/auth/sign_in", nil) + router.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code) +} + +func TestCSRFSkipsTokenAuthenticatedAPIRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(CSRF(CSRFConfig{ + Enabled: true, + Secret: "test-secret", + CookieName: "_gochat_csrf", + HeaderName: "X-CSRF-Token", + TokenLength: 32, + SafeMethods: []string{"GET", "HEAD", "OPTIONS"}, + SkipPaths: []string{"/api/v1/"}, + CookiePath: "/", + CookieSameSite: "Lax", + })) + router.POST("/api/v1/accounts/:account_id/conversations/:conversation_id/messages", func(c *gin.Context) { c.Status(http.StatusOK) }) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/conversations/1/messages", nil) + router.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code) +} diff --git a/internal/middleware/rate_limit.go b/internal/middleware/rate_limit.go index 306c450c..cc8b78c3 100644 --- a/internal/middleware/rate_limit.go +++ b/internal/middleware/rate_limit.go @@ -26,9 +26,9 @@ const ( // Falls back to in-memory limiting when Redis is unavailable. // Reference: Chatwoot's Rack::Attack throttle configuration. type slidingWindowLimiter struct { - redis *redis.Client - cfg *config.RateLimitConfig - fallback *inMemoryLimiter + redis *redis.Client + cfg *config.RateLimitConfig + fallback *inMemoryLimiter redisAvailable atomic.Bool } @@ -287,6 +287,11 @@ func RateLimit(cfg *config.Config, rdb *redis.Client) gin.HandlerFunc { } return func(c *gin.Context) { + if isRateLimitExemptPath(c.Request.URL.Path) { + c.Next() + return + } + ip := c.ClientIP() key := "global:" + ip @@ -317,14 +322,24 @@ func RateLimit(cfg *config.Config, rdb *redis.Client) gin.HandlerFunc { } } +func isRateLimitExemptPath(path string) bool { + switch path { + case "/health", "/metrics": + return true + default: + return false + } +} + // PerRouteLimit creates a per-route rate limiting middleware. // This allows different rate limits for different API endpoints, matching // Chatwoot's Rack::Attack per-route throttle configuration. // // Usage: -// router.GET("/conversations", PerRouteLimit("conversations_list", 60), listConversations) -// router.POST("/messages", PerRouteLimit("messages_create", 30), createMessage) -// router.GET("/reports", PerRouteLimit("reports_read", 10), viewReports) +// +// router.GET("/conversations", PerRouteLimit("conversations_list", 60), listConversations) +// router.POST("/messages", PerRouteLimit("messages_create", 30), createMessage) +// router.GET("/reports", PerRouteLimit("reports_read", 10), viewReports) // // The route identifier is used as a key namespace so that limits on one route // don't affect limits on another route for the same IP. @@ -410,7 +425,8 @@ func PerRouteLimit(route string, requestsPerMin int) gin.HandlerFunc { // endpoints where multiple users may share the same IP (e.g., office networks). // // Usage: -// router.POST("/api/v1/conversations", AuthRequired(jwtSvc), PerUserLimit("conversations_create", 30), createConversation) +// +// router.POST("/api/v1/conversations", AuthRequired(jwtSvc), PerUserLimit("conversations_create", 30), createConversation) func PerUserLimit(route string, requestsPerMin int) gin.HandlerFunc { type visitor struct { count int @@ -526,4 +542,4 @@ func PerUserLimit(route string, requestsPerMin int) gin.HandlerFunc { c.Next() } -} \ No newline at end of file +} diff --git a/internal/pubsub/event_bus.go b/internal/pubsub/event_bus.go index 462b5a53..f1c13547 100644 --- a/internal/pubsub/event_bus.go +++ b/internal/pubsub/event_bus.go @@ -30,9 +30,9 @@ import ( const ( // Message topics (ref: Chatwoot MessageCreated/MessageUpdated events) - TopicMessageCreated = "gochat.message.created" - TopicMessageUpdated = "gochat.message.updated" - TopicMessageDeleted = "gochat.message.deleted" + TopicMessageCreated = "gochat.message.created" + TopicMessageUpdated = "gochat.message.updated" + TopicMessageDeleted = "gochat.message.deleted" // Conversation topics (ref: Chatwoot ConversationStatusChanged events) TopicConversationCreated = "gochat.conversation.created" @@ -40,14 +40,14 @@ const ( TopicConversationResolved = "gochat.conversation.resolved" TopicConversationAssigned = "gochat.conversation.assigned" // 1:1 Chatwoot: additional conversation events - TopicConversationStatusChanged = "gochat.conversation.status_changed" - TopicConversationContactChanged = "gochat.conversation.contact_changed" - TopicConversationRead = "gochat.conversation.read" + TopicConversationStatusChanged = "gochat.conversation.status_changed" + TopicConversationContactChanged = "gochat.conversation.contact_changed" + TopicConversationRead = "gochat.conversation.read" TopicConversationUnreadCountChanged = "gochat.conversation.unread_count_changed" - TopicConversationMentioned = "gochat.conversation.mentioned" - TopicAssigneeChanged = "gochat.conversation.assignee_changed" - TopicTeamChanged = "gochat.conversation.team_changed" - TopicFirstReplyCreated = "gochat.message.first_reply_created" + TopicConversationMentioned = "gochat.conversation.mentioned" + TopicAssigneeChanged = "gochat.conversation.assignee_changed" + TopicTeamChanged = "gochat.conversation.team_changed" + TopicFirstReplyCreated = "gochat.message.first_reply_created" // Contact topics (ref: Chatwoot ContactCreated/ContactUpdated events) TopicContactCreated = "gochat.contact.created" @@ -72,14 +72,15 @@ const ( TopicWebhookReceived = "gochat.webhook.received" TopicSystemNotification = "gochat.system.notification" TopicNotificationCreated = "gochat.notification.created" + TopicNotificationUpdated = "gochat.notification.updated" TopicNotificationDeleted = "gochat.notification.deleted" // Account topics TopicAccountCacheInvalidated = "gochat.account.cache_invalidated" // AccountUser topics — 1:1 Chatwoot: after_create_commit/after_destroy callbacks - TopicAccountUserCreated = "gochat.account_user.created" - TopicAccountUserUpdated = "gochat.account_user.updated" + TopicAccountUserCreated = "gochat.account_user.created" + TopicAccountUserUpdated = "gochat.account_user.updated" TopicAccountUserDestroyed = "gochat.account_user.destroyed" ) @@ -201,4 +202,4 @@ func (l *zapLoggerAdapter) Trace(msg string, fields watermill.LogFields) { func (l *zapLoggerAdapter) With(fields watermill.LogFields) watermill.LoggerAdapter { return l // No field enrichment needed — zap handles structured logging via .L() -} \ No newline at end of file +} diff --git a/internal/repository/account_repo_test.go b/internal/repository/account_repo_test.go index 08213545..a16bd3e2 100644 --- a/internal/repository/account_repo_test.go +++ b/internal/repository/account_repo_test.go @@ -109,6 +109,25 @@ func TestAccountRepo_UpdateActiveAt_OverwritesPrevious(t *testing.T) { assert.WithinDuration(t, secondTime, *refreshed.ActiveAt, time.Second) } +func TestAccountUserRepo_UpdateActiveAt_SetsCurrentTimestamp(t *testing.T) { + db := setupAccountExtensionTestDB(t) + ctx := context.Background() + repo := NewAccountUserRepo(db) + + account := createAccountExtTestAccount(t, db) + user := createAccountExtTestUser(t, db, account.ID) + createAccountExtTestAccountUser(t, db, account.ID, user.ID) + + startedAt := time.Now().UTC() + err := repo.UpdateActiveAt(ctx, account.ID, user.ID) + require.NoError(t, err) + + var refreshed model.AccountUser + require.NoError(t, db.Where("account_id = ? AND user_id = ?", account.ID, user.ID).First(&refreshed).Error) + assert.NotNil(t, refreshed.ActiveAt) + assert.WithinDuration(t, startedAt, *refreshed.ActiveAt, 5*time.Second) +} + // =========================================================================== // FindAccountUserByUserAndAccount tests // =========================================================================== @@ -136,4 +155,4 @@ func TestAccountRepo_FindAccountUserByUserAndAccount_NotFound(t *testing.T) { _, err := repo.FindAccountUserByUserAndAccount(ctx, 9999, 9999) assert.ErrorIs(t, err, gorm.ErrRecordNotFound) -} \ No newline at end of file +} diff --git a/internal/repository/account_user_repo.go b/internal/repository/account_user_repo.go index 3d943603..3ddaeecd 100644 --- a/internal/repository/account_user_repo.go +++ b/internal/repository/account_user_repo.go @@ -2,6 +2,7 @@ package repository import ( "context" + "time" "gorm.io/gorm" @@ -116,7 +117,7 @@ func (r *AccountUserRepo) UpdateActiveAt(ctx context.Context, accountID, userID return r.db.WithContext(ctx). Model(&model.AccountUser{}). Where("account_id = ? AND user_id = ?", accountID, userID). - Update("active_at", gorm.Expr("NOW()")).Error + Update("active_at", time.Now().UTC()).Error } // FindByAccountAndUserOrFail retrieves an AccountUser by accountID and userID. diff --git a/internal/repository/contact_repo.go b/internal/repository/contact_repo.go index 6c82f94e..aa09cbd5 100644 --- a/internal/repository/contact_repo.go +++ b/internal/repository/contact_repo.go @@ -84,12 +84,12 @@ func (r *ContactRepo) Search(ctx context.Context, accountID uint, query string, if query != "" { if searchMode == search.SearchModeTrigram { // pg_trgm fuzzy match on contact fields - condition = condition.Where("name % ? OR email % ? OR phone_number ILIKE ? OR identifier % ?", + condition = condition.Where("contacts.name % ? OR contacts.email % ? OR contacts.phone_number ILIKE ? OR contacts.identifier % ?", query, query, query, query) } else { // Case-insensitive substring match that works on PostgreSQL and SQLite tests. likeQuery := "%" + query + "%" - condition = condition.Where("LOWER(name) LIKE LOWER(?) OR LOWER(email) LIKE LOWER(?) OR LOWER(phone_number) LIKE LOWER(?) OR LOWER(identifier) LIKE LOWER(?)", + condition = condition.Where("LOWER(contacts.name) LIKE LOWER(?) OR LOWER(contacts.email) LIKE LOWER(?) OR LOWER(contacts.phone_number) LIKE LOWER(?) OR LOWER(contacts.identifier) LIKE LOWER(?)", likeQuery, likeQuery, likeQuery, likeQuery) } } @@ -194,6 +194,7 @@ func contactFeatureFlagEnabled(raw, flag string) bool { func resolveContactSort(sort string) string { allowedSorts := map[string]string{ "name": "contacts.name ASC", + "date": "contacts.created_at DESC", "email": "contacts.email ASC", "created_at": "contacts.created_at DESC", "last_activity_at": "contacts.last_activity_at DESC NULLS LAST, contacts.id DESC", diff --git a/internal/repository/notification_repo.go b/internal/repository/notification_repo.go index bdbdb8af..28e2332d 100644 --- a/internal/repository/notification_repo.go +++ b/internal/repository/notification_repo.go @@ -221,6 +221,14 @@ func (r *NotificationRepo) CountUnreadByUserAndAccount(ctx context.Context, user return count, err } +func (r *NotificationRepo) CountByUserAndAccount(ctx context.Context, userID, accountID uint) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&model.Notification{}). + Where("user_id = ? AND account_id = ?", userID, accountID). + Count(&count).Error + return count, err +} + // Snooze sets the snoozed_until timestamp and clears read_at for a notification. // Returns the updated notification. // Reference: Chatwoot notifications_controller.rb#snooze @@ -261,7 +269,7 @@ func (r *NotificationRepo) Snooze(ctx context.Context, id, userID, accountID uin func (r *NotificationRepo) MarkUnread(ctx context.Context, id, userID, accountID uint) (*model.Notification, error) { result := r.db.WithContext(ctx).Model(&model.Notification{}). Where("id = ? AND user_id = ? AND account_id = ?", id, userID, accountID). - Update("read_at", nil) + Updates(map[string]interface{}{"read_at": gorm.Expr("NULL")}) if result.Error != nil { return nil, result.Error } diff --git a/internal/router/channel_callbacks.go b/internal/router/channel_callbacks.go index fa54d9d9..5b562387 100644 --- a/internal/router/channel_callbacks.go +++ b/internal/router/channel_callbacks.go @@ -208,11 +208,15 @@ func twitterChannelCallback(db *gorm.DB) gin.HandlerFunc { c.Redirect(http.StatusFound, newInboxURL(accountID, "twitter", nil)) return } - inbox, _, err := upsertTwitterInbox(c, db, accountID, body) + inbox, existed, err := upsertTwitterInbox(c, db, accountID, body) if err != nil { c.Redirect(http.StatusFound, newInboxURL(accountID, "twitter", nil)) return } + if existed { + c.Redirect(http.StatusFound, inboxSettingsURL(accountID, inbox.ID)) + return + } c.Redirect(http.StatusFound, inboxAgentsURL(accountID, inbox.ID)) } } @@ -264,6 +268,7 @@ func upsertInstagramInbox(c *gin.Context, db *gorm.DB, accountID uint, instagram return nil, false, err } inbox.Name = username + inbox.ChannelConfig = instagramInboxConfig(channel) return &inbox, true, db.WithContext(c.Request.Context()).Save(&inbox).Error } channel = channelmodel.ChannelInstagram{AccountID: accountID, InstagramAccountID: instagramID, InstagramBusinessAccountID: body["instagram_business_account_id"], PageAccessToken: body["access_token"], ConnectedFBPageID: firstNonBlank(body["connected_fb_page_id"], body["page_id"]), InstagramAccountName: username} @@ -273,7 +278,7 @@ func upsertInstagramInbox(c *gin.Context, db *gorm.DB, accountID uint, instagram if err := db.WithContext(c.Request.Context()).Create(&channel).Error; err != nil { return nil, false, err } - inbox := model.Inbox{AccountID: accountID, Name: username, ChannelType: "instagram", ChannelID: channel.ID, Enabled: true} + inbox := model.Inbox{AccountID: accountID, Name: username, ChannelType: "instagram", ChannelID: channel.ID, Enabled: true, ChannelConfig: instagramInboxConfig(channel)} if err := db.WithContext(c.Request.Context()).Create(&inbox).Error; err != nil { return nil, false, err } @@ -297,13 +302,14 @@ func upsertTikTokInbox(c *gin.Context, db *gorm.DB, accountID uint, businessID, return nil, false, err } inbox.Name = name + inbox.ChannelConfig = tiktokInboxConfig(channel) return &inbox, true, db.WithContext(c.Request.Context()).Save(&inbox).Error } channel = channelmodel.ChannelTikTok{AccountID: accountID, TikTokBusinessID: businessID, AccessToken: body["access_token"], RefreshToken: body["refresh_token"], TokenExpiresAt: expiresAt} if err := db.WithContext(c.Request.Context()).Create(&channel).Error; err != nil { return nil, false, err } - inbox := model.Inbox{AccountID: accountID, Name: name, ChannelType: "tiktok", ChannelID: channel.ID, Enabled: true} + inbox := model.Inbox{AccountID: accountID, Name: name, ChannelType: "tiktok", ChannelID: channel.ID, Enabled: true, ChannelConfig: tiktokInboxConfig(channel)} if err := db.WithContext(c.Request.Context()).Create(&inbox).Error; err != nil { return nil, false, err } @@ -327,13 +333,18 @@ func upsertTwitterInbox(c *gin.Context, db *gorm.DB, accountID uint, values url. if err := db.WithContext(c.Request.Context()).Where("id = ? AND account_id = ?", channel.InboxID, accountID).First(&inbox).Error; err != nil { return nil, false, err } + inbox.Name = name + inbox.ChannelConfig = twitterInboxConfig(channel) + if err := db.WithContext(c.Request.Context()).Save(&inbox).Error; err != nil { + return nil, false, err + } return &inbox, true, nil } channel = channelmodel.ChannelTwitter{AccountID: accountID, TwitterUserID: values.Get("user_id"), TwitterAccessToken: values.Get("oauth_token"), TwitterAccessTokenSecret: values.Get("oauth_token_secret"), ScreenName: values.Get("screen_name"), Name: name, AccessToken: values.Get("oauth_token")} if err := db.WithContext(c.Request.Context()).Create(&channel).Error; err != nil { return nil, false, err } - inbox := model.Inbox{AccountID: accountID, Name: name, ChannelType: "twitter", ChannelID: channel.ID, Enabled: true} + inbox := model.Inbox{AccountID: accountID, Name: name, ChannelType: "twitter", ChannelID: channel.ID, Enabled: true, ChannelConfig: twitterInboxConfig(channel)} if err := db.WithContext(c.Request.Context()).Create(&inbox).Error; err != nil { return nil, false, err } @@ -359,6 +370,41 @@ func oauthInboxConfig(provider string, tokenBody map[string]string) string { return string(encoded) } +func instagramInboxConfig(channel channelmodel.ChannelInstagram) string { + encoded, _ := json.Marshal(map[string]any{ + "instagram_id": channel.InstagramAccountID, + "instagram_account_id": channel.InstagramAccountID, + "instagram_business_account_id": channel.InstagramBusinessAccountID, + "instagram_account_name": channel.InstagramAccountName, + "connected_fb_page_id": channel.ConnectedFBPageID, + "page_access_token": channel.PageAccessToken, + "reauthorization_required": channel.ReauthorizationRequired, + }) + return string(encoded) +} + +func tiktokInboxConfig(channel channelmodel.ChannelTikTok) string { + encoded, _ := json.Marshal(map[string]any{ + "tiktok_business_id": channel.TikTokBusinessID, + "access_token": channel.AccessToken, + "refresh_token": channel.RefreshToken, + "webhook_verify_token": channel.WebhookVerifyToken, + "reauthorization_required": channel.ReauthorizationRequired, + }) + return string(encoded) +} + +func twitterInboxConfig(channel channelmodel.ChannelTwitter) string { + encoded, _ := json.Marshal(map[string]any{ + "twitter_user_id": channel.TwitterUserID, + "screen_name": channel.ScreenName, + "twitter_access_token": channel.TwitterAccessToken, + "twitter_access_token_secret": channel.TwitterAccessTokenSecret, + "tweets_enabled": true, + }) + return string(encoded) +} + func compactStringMap(values map[string]string) map[string]string { result := make(map[string]string, len(values)) for key, value := range values { diff --git a/internal/router/router.go b/internal/router/router.go index 3fed67d6..00570038 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -584,6 +584,16 @@ func registerEnterpriseRoutes(g *gin.RouterGroup, h *Handlers) { accounts.POST("/:account_id/toggle_deletion", h.EnterpriseAccount.ToggleDeletion) accounts.POST("/:account_id/topup_checkout", h.EnterpriseAccount.TopupCheckout) } + + // EnterpriseAccountAPI uses an empty resource with accountScoped=true. In a + // mounted dashboard route ApiClient expands to /accounts/:id/*, while its + // frontend unit specs and some boot-time calls hit these literal paths and + // rely on the authenticated current account context. + g.POST("/checkout", h.EnterpriseAccount.Checkout) + g.POST("/subscription", h.EnterpriseAccount.Subscription) + g.GET("/limits", h.EnterpriseAccount.Limits) + g.POST("/toggle_deletion", h.EnterpriseAccount.ToggleDeletion) + g.POST("/topup_checkout", h.EnterpriseAccount.TopupCheckout) } // registerV1Routes maps all API v1 resource routes. @@ -655,6 +665,9 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // Account routes — scoped with AccountScope middleware (ref: Chatwoot namespace :accounts) // GetAll is outside AccountScope — platform admin level listing of all accounts g.GET("/accounts/all", h.Account.GetAll) + // Chatwoot account creation is user-scoped and the frontend posts to the + // no-trailing-slash collection path before a new account id exists. + g.POST("/accounts", h.Account.Create) accounts := g.Group("/accounts") accounts.Use(middleware.AccountScope()) @@ -731,7 +744,9 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // Inbox routes (ref: Chatwoot nested resources :inboxes) inboxes := accountScoped.Group("/inboxes") { + inboxes.GET("", h.Inbox.List) inboxes.GET("/", h.Inbox.List) + inboxes.POST("", h.Inbox.Create) inboxes.POST("/", h.Inbox.Create) inboxes.GET("/:inbox_id", h.Inbox.Get) inboxes.PUT("/:inbox_id", h.Inbox.Update) @@ -999,7 +1014,9 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // Conversation routes (ref: Chatwoot resources :conversations) conversations := accountScoped.Group("/conversations") { + conversations.GET("", h.Conversation.List) conversations.GET("/", h.Conversation.List) + conversations.POST("", h.Conversation.Create) conversations.POST("/", h.Conversation.Create) conversations.GET("/search", h.Conversation.Search) conversations.POST("/filter", h.Conversation.Filter) @@ -1010,9 +1027,9 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { conversations.POST("/:conversation_id/assign", h.Conversation.AssignAgent) conversations.POST("/:conversation_id/toggle_status", h.Conversation.ToggleStatus) conversations.POST("/:conversation_id/toggle_priority", h.Conversation.TogglePriority) - conversations.PATCH("/:conversation_id/labels", h.Label.ReplaceConversationLabels) - conversations.GET("/:conversation_id/labels", h.Label.GetConversationLabels) - conversations.POST("/:conversation_id/labels", h.Label.AddLabelToConversation) + conversations.PATCH("/:conversation_id/labels", h.Conversation.UpdateLabels) + conversations.GET("/:conversation_id/labels", h.Conversation.GetLabels) + conversations.POST("/:conversation_id/labels", h.Conversation.UpdateLabels) conversations.DELETE("/:conversation_id/labels/:tag_id", h.Label.RemoveLabelFromConversation) conversations.POST("/:conversation_id/mute", h.Conversation.Mute) conversations.POST("/:conversation_id/unmute", h.Conversation.Unmute) @@ -1068,7 +1085,9 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // Messages nested under conversation msgs := conversations.Group("/:conversation_id/messages") { + msgs.GET("", h.Conversation.ListMessages) msgs.GET("/", h.Conversation.ListMessages) + msgs.POST("", h.Message.Create) msgs.POST("/", h.Message.Create) msgs.GET("/:message_id", h.Message.Get) msgs.PATCH("/:message_id", h.Message.Update) @@ -1124,7 +1143,9 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // Contact routes (ref: Chatwoot resources :contacts) contacts := accountScoped.Group("/contacts") { + contacts.GET("", h.Contact.List) contacts.GET("/", h.Contact.List) + contacts.POST("", h.Contact.Create) contacts.POST("/", h.Contact.Create) contacts.GET("/search", h.Contact.Search) contacts.POST("/filter", h.Contact.Filter) @@ -1176,7 +1197,9 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // G4: Company routes (CRUD + search + nested contacts/conversations/notes) companies := accountScoped.Group("/companies") { + companies.GET("", h.Company.List) companies.GET("/", h.Company.List) + companies.POST("", h.Company.Create) companies.POST("/", h.Company.Create) companies.GET("/search", h.Company.Search) companies.GET("/:company_id", h.Company.Get) @@ -1300,7 +1323,9 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // Assistant CRUD assistants := captain.Group("/assistants") { + assistants.GET("", h.CaptainAssistant.List) assistants.GET("/", h.CaptainAssistant.List) + assistants.POST("", h.CaptainAssistant.Create) assistants.POST("/", h.CaptainAssistant.Create) assistants.GET("/tools", h.CaptainAssistant.Tools) assistants.GET("/:assistant_id", h.CaptainAssistant.Get) @@ -1369,14 +1394,18 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // Copilot features (ref: Chatwoot Captain::Copilot) copilotThreads := captain.Group("/copilot_threads") { + copilotThreads.GET("", h.Copilot.ListThreads) copilotThreads.GET("/", h.Copilot.ListThreads) + copilotThreads.POST("", h.Copilot.CreateThread) copilotThreads.POST("/", h.Copilot.CreateThread) copilotThreads.GET("/:thread_id", h.Copilot.GetThread) copilotThreads.DELETE("/:thread_id", h.Copilot.DeleteThread) // Nested copilot_messages (Chatwoot: resources :copilot_messages, only: [:index, :create]) copilotThreadMessages := copilotThreads.Group("/:thread_id/copilot_messages") { + copilotThreadMessages.GET("", h.Copilot.ListSuggestionMessages) copilotThreadMessages.GET("/", h.Copilot.ListSuggestionMessages) + copilotThreadMessages.POST("", h.Copilot.SendMessage) copilotThreadMessages.POST("/", h.Copilot.SendMessage) } copilotThreads.POST("/:thread_id/messages", h.Copilot.SendMessage) @@ -1524,22 +1553,24 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { } // Knowledge Base / Help Center routes (M9) - // Reference: Chatwoot knowledge_base routes - // Feature gate: knowledge_base must be enabled on the account - portals := accountScoped.Group("/portals", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase)) + // Reference: Chatwoot knowledge_base routes. The reused Chatwoot dashboard + // unconditionally fetches the portal list during boot, so reads must return + // a Chatwoot-compatible empty payload even when the feature is disabled. + // Mutations remain feature-gated. + portals := accountScoped.Group("/portals") { portals.GET("", h.Portal.List) portals.GET("/", h.Portal.List) - portals.POST("", h.Portal.Create) - portals.POST("/", h.Portal.Create) + portals.POST("", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Create) + portals.POST("/", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Create) portals.GET("/:portal_id", h.Portal.Get) - portals.PATCH("/:portal_id", h.Portal.Update) - portals.PUT("/:portal_id", h.Portal.Update) - portals.DELETE("/:portal_id", h.Portal.Delete) - portals.PATCH("/:portal_id/archive", h.Portal.Archive) - portals.POST("/:portal_id/archive", h.Portal.Archive) - portals.DELETE("/:portal_id/logo", h.Portal.RemoveLogo) - portals.POST("/:portal_id/send_instructions", h.Portal.SendInstructions) + portals.PATCH("/:portal_id", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Update) + portals.PUT("/:portal_id", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Update) + portals.DELETE("/:portal_id", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Delete) + portals.PATCH("/:portal_id/archive", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Archive) + portals.POST("/:portal_id/archive", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Archive) + portals.DELETE("/:portal_id/logo", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.RemoveLogo) + portals.POST("/:portal_id/send_instructions", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.SendInstructions) portals.GET("/:portal_id/ssl_status", h.Portal.SSLStatus) // Categories nested under portal @@ -1607,7 +1638,9 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // Reference: Chatwoot namespace :automation_rules automationRules := accountScoped.Group("/automation_rules") { + automationRules.GET("", h.AutomationRule.List) automationRules.GET("/", h.AutomationRule.List) + automationRules.POST("", h.AutomationRule.Create) automationRules.POST("/", h.AutomationRule.Create) automationRules.GET("/:automation_id", h.AutomationRule.Get) automationRules.PUT("/:automation_id", h.AutomationRule.Update) @@ -1620,7 +1653,9 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // Reference: Chatwoot namespace :macros macros := accountScoped.Group("/macros") { + macros.GET("", h.Macro.List) macros.GET("/", h.Macro.List) + macros.POST("", h.Macro.Create) macros.POST("/", h.Macro.Create) macros.GET("/:macro_id", h.Macro.Get) macros.PUT("/:macro_id", h.Macro.Update) @@ -1661,6 +1696,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // Reference: Chatwoot namespace :csat_survey_responses csats := accountScoped.Group("/csat_survey_responses") { + csats.GET("", h.CsatSurvey.List) csats.GET("/", h.CsatSurvey.List) csats.GET("/metrics", h.CsatSurvey.Metrics) csats.GET("/download", h.CsatSurvey.Download) diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 1b932c06..dd984f49 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -1,6 +1,7 @@ package router import ( + "context" "encoding/json" "io" "net/http" @@ -11,10 +12,14 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/config" + v1 "github.com/gochat/gochat/internal/handler/api/v1" "github.com/gochat/gochat/internal/middleware" "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/golang-jwt/jwt/v5" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -74,6 +79,7 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) { "GET /api/v2/accounts/:account_id/reports/summary", "GET /api/v2/accounts/:account_id/year_in_review", "GET /api/v2/accounts/:account_id/live_reports/grouped_conversation_metrics", + "POST /api/v1/accounts", "GET /webhooks/twitter", "POST /webhooks/twitter", "POST /webhooks/telegram/:bot_token", @@ -98,6 +104,87 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) { } } +func TestAPIV2LiveReportsRouterAuthAndAccountScope(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("open db: %v", err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("db handle: %v", err) + } + defer sqlDB.Close() + if err := db.AutoMigrate(&model.Conversation{}, &model.ReportingEvent{}, &model.ReportingEventsRollup{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + analyticsSvc := service.NewAnalyticsService(repository.NewReportingEventRepo(db), repository.NewReportingEventsRollupRepo(db)) + jwtCfg := &config.JWTConfig{Secret: "live-report-router-secret", ExpiryHours: 1, RefreshExpiryHours: 24, AccessExpiryMinutes: 60} + jwtSvc := auth.NewJWTService(jwtCfg) + user := &model.User{Base: model.Base{ID: 7}, Provider: "email", Email: "agent@example.com"} + tokenPair, err := jwtSvc.GenerateTokenPair(user, 1, "agent") + if err != nil { + t.Fatalf("generate token: %v", err) + } + refreshStore := auth.NewRefreshTokenStore(nil, jwtCfg) + if err := refreshStore.Store(context.Background(), user.ID, tokenPair.RefreshToken); err != nil { + t.Fatalf("store refresh token: %v", err) + } + + open := model.Conversation{AccountID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget"} + otherAccountOpen := model.Conversation{AccountID: 2, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget"} + if err := db.Create(&open).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + if err := db.Create(&otherAccountOpen).Error; err != nil { + t.Fatalf("create other conversation: %v", err) + } + + engine := gin.New() + RegisterRoutes( + engine, + jwtSvc, + refreshStore, + nil, + &Handlers{LiveReport: v1.NewLiveReportHandler(analyticsSvc)}, + nil, + nil, + jwtCfg, + middleware.CORSConfig{}, + db, + ) + + unauthorized := httptest.NewRecorder() + engine.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/live_reports/conversation_metrics", nil)) + if unauthorized.Code != http.StatusUnauthorized { + t.Fatalf("expected no-token request to be unauthorized, got %d: %s", unauthorized.Code, unauthorized.Body.String()) + } + + authorized := httptest.NewRecorder() + authorizedReq := httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/live_reports/conversation_metrics", nil) + authorizedReq.Header.Set("access-token", tokenPair.AccessToken) + engine.ServeHTTP(authorized, authorizedReq) + if authorized.Code != http.StatusOK { + t.Fatalf("expected Chatwoot access-token request to pass, got %d: %s", authorized.Code, authorized.Body.String()) + } + var body map[string]interface{} + if err := json.Unmarshal(authorized.Body.Bytes(), &body); err != nil { + t.Fatalf("decode authorized body: %v", err) + } + if body["open"] != float64(1) || body["unattended"] != float64(1) || body["pending"] != float64(0) { + t.Fatalf("expected account-scoped live metrics, got %#v", body) + } + + forbidden := httptest.NewRecorder() + forbiddenReq := httptest.NewRequest(http.MethodGet, "/api/v2/accounts/2/live_reports/conversation_metrics", nil) + forbiddenReq.Header.Set("access-token", tokenPair.AccessToken) + engine.ServeHTTP(forbidden, forbiddenReq) + if forbidden.Code != http.StatusForbidden { + t.Fatalf("expected token scoped to account 1 to be forbidden from account 2, got %d: %s", forbidden.Code, forbidden.Body.String()) + } +} + func TestWebhookNilHandlerReturnsProviderUnavailable(t *testing.T) { gin.SetMode(gin.TestMode) engine := gin.New() @@ -347,6 +434,56 @@ func TestIntegrationCallbacksRedirectSafelyOnInvalidState(t *testing.T) { } } +func TestIntegrationCallbacksRedirectProviderErrorsWithoutCreatingHooks(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("FRONTEND_URL", "https://app.example.test") + t.Setenv("LINEAR_CLIENT_ID", "linear-client") + t.Setenv("LINEAR_CLIENT_SECRET", "linear-secret") + t.Setenv("SHOPIFY_CLIENT_ID", "shopify-client") + t.Setenv("SHOPIFY_CLIENT_SECRET", "shopify-secret") + t.Setenv("NOTION_CLIENT_ID", "notion-client") + t.Setenv("NOTION_CLIENT_SECRET", "notion-secret") + t.Setenv("LINEAR_OAUTH_TOKEN_URL", "https://oauth.example.test/linear") + t.Setenv("SHOPIFY_OAUTH_TOKEN_URL", "https://oauth.example.test/shopify") + t.Setenv("NOTION_OAUTH_TOKEN_URL", "https://oauth.example.test/notion") + withFakeOAuthTransport(t, map[string]map[string]any{ + "/linear": {"token_type": "bearer", "scope": "read,write"}, + "/shopify": {"scope": "read_orders"}, + "/notion": {"token_type": "bearer", "workspace_name": "Docs"}, + }) + + db, account := setupRouterIntegrationCallbackDB(t) + engine := gin.New() + engine.GET("/linear/callback", linearIntegrationCallback(db)) + engine.GET("/shopify/callback", shopifyIntegrationCallback(db)) + engine.GET("/notion/callback", notionIntegrationCallback(db)) + + checks := []struct { + name string + path string + secret string + location string + }{ + {name: "linear", path: "/linear/callback?code=linear-code", secret: "linear-secret", location: "https://app.example.test/app/accounts/1/settings/integrations/linear"}, + {name: "shopify", path: "/shopify/callback?code=shopify-code&shop=store.myshopify.com", secret: "shopify-secret", location: "https://app.example.test/app/accounts/1/settings/integrations/shopify?error=true"}, + {name: "notion", path: "/notion/callback?code=notion-code", secret: "notion-secret", location: "https://app.example.test"}, + } + for _, check := range checks { + resp := performGet(engine, check.path+"&state="+url.QueryEscape(signedCallbackState(t, account.ID, check.secret))) + if resp.Code != http.StatusFound || resp.Header().Get("Location") != check.location { + t.Fatalf("expected %s provider-error redirect to %q, got %d %q", check.name, check.location, resp.Code, resp.Header().Get("Location")) + } + } + + var count int64 + if err := db.Model(&model.IntegrationHook{}).Count(&count).Error; err != nil { + t.Fatalf("failed to count hooks: %v", err) + } + if count != 0 { + t.Fatalf("expected provider-error callbacks not to create hooks, got %d", count) + } +} + func TestChannelCallbacksCreateInboxesAndRedirect(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("FRONTEND_URL", "https://app.example.test") @@ -429,14 +566,177 @@ func TestChannelCallbacksCreateInboxesAndRedirect(t *testing.T) { if err := db.Where("instagram_account_id = ?", "ig-1").First(&instagram).Error; err != nil || instagram.PageAccessToken != "instagram-token" { t.Fatalf("expected instagram channel, channel=%+v err=%v", instagram, err) } + instagramInboxConfig := callbackTestInboxConfig(t, db, "instagram", instagram.ID) + if instagramInboxConfig["instagram_id"] != "ig-1" || instagramInboxConfig["connected_fb_page_id"] != "page-1" || instagramInboxConfig["page_access_token"] != "instagram-token" { + t.Fatalf("expected instagram inbox channel config, got %+v", instagramInboxConfig) + } var tiktok channelmodel.ChannelTikTok if err := db.Where("tiktok_business_id = ?", "biz-1").First(&tiktok).Error; err != nil || tiktok.AccessToken != "tiktok-token" { t.Fatalf("expected tiktok channel, channel=%+v err=%v", tiktok, err) } + tiktokInboxConfig := callbackTestInboxConfig(t, db, "tiktok", tiktok.ID) + if tiktokInboxConfig["tiktok_business_id"] != "biz-1" || tiktokInboxConfig["access_token"] != "tiktok-token" || tiktokInboxConfig["refresh_token"] != "tiktok-refresh" { + t.Fatalf("expected tiktok inbox channel config, got %+v", tiktokInboxConfig) + } var twitter channelmodel.ChannelTwitter if err := db.Where("twitter_user_id = ?", "tw-1").First(&twitter).Error; err != nil || twitter.TwitterAccessToken != "twitter-token" { t.Fatalf("expected twitter channel, channel=%+v err=%v", twitter, err) } + twitterInboxConfig := callbackTestInboxConfig(t, db, "twitter", twitter.ID) + if twitterInboxConfig["twitter_user_id"] != "tw-1" || twitterInboxConfig["screen_name"] != "tw_support" || twitterInboxConfig["tweets_enabled"] != true { + t.Fatalf("expected twitter inbox channel config, got %+v", twitterInboxConfig) + } +} + +func TestChannelCallbacksUpdateExistingInboxesAndRedirectToSettings(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("FRONTEND_URL", "https://app.example.test") + t.Setenv("GOOGLE_OAUTH_CLIENT_ID", "google-client") + t.Setenv("GOOGLE_OAUTH_CLIENT_SECRET", "google-secret") + t.Setenv("INSTAGRAM_APP_ID", "instagram-client") + t.Setenv("INSTAGRAM_APP_SECRET", "instagram-secret") + t.Setenv("TIKTOK_APP_ID", "tiktok-client") + t.Setenv("TIKTOK_APP_SECRET", "tiktok-secret") + t.Setenv("TWITTER_CONSUMER_SECRET", "twitter-secret") + t.Setenv("GOOGLE_OAUTH_TOKEN_URL", "https://oauth.example.test/google") + t.Setenv("INSTAGRAM_OAUTH_TOKEN_URL", "https://oauth.example.test/instagram") + t.Setenv("TIKTOK_OAUTH_TOKEN_URL", "https://oauth.example.test/tiktok") + t.Setenv("TWITTER_OAUTH_TOKEN_URL", "https://oauth.example.test/twitter") + + withFakeCallbackTransport(t, map[string]string{ + "/google": jsonOAuthBody(t, map[string]any{"access_token": "google-new", "refresh_token": "google-refresh-new", "id_token": idToken(t, map[string]any{"email": "gmail@example.com", "name": "Gmail Renamed"})}), + "/instagram": jsonOAuthBody(t, map[string]any{"access_token": "instagram-new", "instagram_account_id": "ig-existing", "username": "insta_renamed", "connected_fb_page_id": "page-new"}), + "/tiktok": jsonOAuthBody(t, map[string]any{"access_token": "tiktok-new", "refresh_token": "tiktok-refresh-new", "business_id": "biz-existing", "display_name": "TikTok Renamed", "expires_in": 3600}), + "/twitter": "oauth_token=twitter-new&oauth_token_secret=twitter-secret-new&user_id=tw-existing&screen_name=tw_renamed", + }) + + db, account := setupRouterChannelCallbackDB(t) + googleChannel := channelmodel.ChannelEmail{AccountID: account.ID, Email: "gmail@example.com", MailboxName: "Old Gmail", Domain: "example.com", IMAPLogin: "gmail@example.com", IMAPAddress: "old.imap", IMAPPort: 993, IMAPEnabled: true} + requireRouterCreate(t, db, &googleChannel) + googleInbox := model.Inbox{AccountID: account.ID, Name: "Old Gmail", ChannelType: "email", ChannelID: googleChannel.ID, Enabled: true, ChannelConfig: `{"provider":"google","provider_config":{"access_token":"old"}}`} + requireRouterCreate(t, db, &googleInbox) + googleChannel.InboxID = googleInbox.ID + requireRouterSave(t, db, &googleChannel) + + instagramChannel := channelmodel.ChannelInstagram{AccountID: account.ID, InboxID: 1, InstagramAccountID: "ig-existing", PageAccessToken: "instagram-old", ConnectedFBPageID: "page-old", InstagramAccountName: "insta_old"} + requireRouterCreate(t, db, &instagramChannel) + instagramInbox := model.Inbox{AccountID: account.ID, Name: "insta_old", ChannelType: "instagram", ChannelID: instagramChannel.ID, Enabled: true, ChannelConfig: instagramInboxConfig(instagramChannel)} + requireRouterCreate(t, db, &instagramInbox) + instagramChannel.InboxID = instagramInbox.ID + requireRouterSave(t, db, &instagramChannel) + + tiktokChannel := channelmodel.ChannelTikTok{AccountID: account.ID, InboxID: 1, TikTokBusinessID: "biz-existing", AccessToken: "tiktok-old", RefreshToken: "tiktok-refresh-old"} + requireRouterCreate(t, db, &tiktokChannel) + tiktokInbox := model.Inbox{AccountID: account.ID, Name: "TikTok Old", ChannelType: "tiktok", ChannelID: tiktokChannel.ID, Enabled: true, ChannelConfig: tiktokInboxConfig(tiktokChannel)} + requireRouterCreate(t, db, &tiktokInbox) + tiktokChannel.InboxID = tiktokInbox.ID + requireRouterSave(t, db, &tiktokChannel) + + twitterChannel := channelmodel.ChannelTwitter{AccountID: account.ID, InboxID: 1, TwitterUserID: "tw-existing", TwitterAccessToken: "twitter-old", TwitterAccessTokenSecret: "twitter-secret-old", ScreenName: "tw_old", Name: "tw_old", AccessToken: "twitter-old"} + requireRouterCreate(t, db, &twitterChannel) + twitterInbox := model.Inbox{AccountID: account.ID, Name: "tw_old", ChannelType: "twitter", ChannelID: twitterChannel.ID, Enabled: true, ChannelConfig: twitterInboxConfig(twitterChannel)} + requireRouterCreate(t, db, &twitterInbox) + twitterChannel.InboxID = twitterInbox.ID + requireRouterSave(t, db, &twitterChannel) + + engine := gin.New() + engine.GET("/google/callback", googleEmailCallback(db)) + engine.GET("/instagram/callback", instagramChannelCallback(db)) + engine.GET("/tiktok/callback", tiktokChannelCallback(db)) + engine.GET("/twitter/callback", twitterChannelCallback(db)) + + checks := []struct { + path string + secret string + inboxID uint + }{ + {"/google/callback?code=google-code", "google-secret", googleInbox.ID}, + {"/instagram/callback?code=instagram-code", "instagram-secret", instagramInbox.ID}, + {"/tiktok/callback?code=tiktok-code", "tiktok-secret", tiktokInbox.ID}, + {"/twitter/callback?oauth_token=request-token&oauth_verifier=verifier", "twitter-secret", twitterInbox.ID}, + } + for _, check := range checks { + state := signedCallbackState(t, account.ID, check.secret) + resp := performGet(engine, check.path+"&state="+url.QueryEscape(state)) + expectedLocation := inboxSettingsURL(account.ID, check.inboxID) + if resp.Code != http.StatusFound || resp.Header().Get("Location") != expectedLocation { + t.Fatalf("expected settings redirect %q for %s, got %d %q", expectedLocation, check.path, resp.Code, resp.Header().Get("Location")) + } + } + + var updatedGoogle channelmodel.ChannelEmail + requireRouterFirst(t, db.Where("email = ?", "gmail@example.com"), &updatedGoogle) + if updatedGoogle.MailboxName != "Gmail Renamed" || updatedGoogle.IMAPAddress != "imap.gmail.com" { + t.Fatalf("expected updated google channel, got %+v", updatedGoogle) + } + googleConfig := callbackTestInboxConfig(t, db, "email", updatedGoogle.ID) + if providerConfig := googleConfig["provider_config"].(map[string]any); googleConfig["provider"] != "google" || providerConfig["access_token"] != "google-new" { + t.Fatalf("expected updated google inbox config, got %+v", googleConfig) + } + + var updatedInstagram channelmodel.ChannelInstagram + requireRouterFirst(t, db.Where("instagram_account_id = ?", "ig-existing"), &updatedInstagram) + if updatedInstagram.PageAccessToken != "instagram-new" || updatedInstagram.InstagramAccountName != "insta_renamed" { + t.Fatalf("expected updated instagram channel, got %+v", updatedInstagram) + } + instagramConfig := callbackTestInboxConfig(t, db, "instagram", updatedInstagram.ID) + if instagramConfig["page_access_token"] != "instagram-new" || instagramConfig["instagram_account_name"] != "insta_renamed" { + t.Fatalf("expected updated instagram inbox config, got %+v", instagramConfig) + } + + var updatedTikTok channelmodel.ChannelTikTok + requireRouterFirst(t, db.Where("tiktok_business_id = ?", "biz-existing"), &updatedTikTok) + if updatedTikTok.AccessToken != "tiktok-new" || updatedTikTok.RefreshToken != "tiktok-refresh-new" { + t.Fatalf("expected updated tiktok channel, got %+v", updatedTikTok) + } + tiktokConfig := callbackTestInboxConfig(t, db, "tiktok", updatedTikTok.ID) + if tiktokConfig["access_token"] != "tiktok-new" || tiktokConfig["refresh_token"] != "tiktok-refresh-new" { + t.Fatalf("expected updated tiktok inbox config, got %+v", tiktokConfig) + } + + var updatedTwitter channelmodel.ChannelTwitter + requireRouterFirst(t, db.Where("twitter_user_id = ?", "tw-existing"), &updatedTwitter) + if updatedTwitter.TwitterAccessToken != "twitter-new" || updatedTwitter.ScreenName != "tw_renamed" { + t.Fatalf("expected updated twitter channel, got %+v", updatedTwitter) + } + twitterConfig := callbackTestInboxConfig(t, db, "twitter", updatedTwitter.ID) + if twitterConfig["twitter_access_token"] != "twitter-new" || twitterConfig["screen_name"] != "tw_renamed" { + t.Fatalf("expected updated twitter inbox config, got %+v", twitterConfig) + } +} + +func requireRouterCreate(t *testing.T, db *gorm.DB, value any) { + t.Helper() + if err := db.Create(value).Error; err != nil { + t.Fatalf("create %T: %v", value, err) + } +} + +func requireRouterSave(t *testing.T, db *gorm.DB, value any) { + t.Helper() + if err := db.Save(value).Error; err != nil { + t.Fatalf("save %T: %v", value, err) + } +} + +func requireRouterFirst(t *testing.T, query *gorm.DB, value any) { + t.Helper() + if err := query.First(value).Error; err != nil { + t.Fatalf("load %T: %v", value, err) + } +} + +func callbackTestInboxConfig(t *testing.T, db *gorm.DB, channelType string, channelID uint) map[string]any { + t.Helper() + var inbox model.Inbox + if err := db.Where("channel_type = ? AND channel_id = ?", channelType, channelID).First(&inbox).Error; err != nil { + t.Fatalf("failed to load %s callback inbox: %v", channelType, err) + } + var config map[string]any + if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err != nil { + t.Fatalf("invalid %s callback inbox channel config %q: %v", channelType, inbox.ChannelConfig, err) + } + return config } func TestChannelCallbacksRedirectErrorsToNewInbox(t *testing.T) { @@ -465,6 +765,47 @@ func TestChannelCallbacksRedirectErrorsToNewInbox(t *testing.T) { } } +func TestEmailOAuthCallbacksRedirectProviderErrorsWithoutCreatingInboxes(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("FRONTEND_URL", "https://app.example.test") + t.Setenv("GOOGLE_OAUTH_CLIENT_ID", "google-client") + t.Setenv("GOOGLE_OAUTH_CLIENT_SECRET", "google-secret") + t.Setenv("AZURE_APP_ID", "microsoft-client") + t.Setenv("AZURE_APP_SECRET", "microsoft-secret") + t.Setenv("GOOGLE_OAUTH_TOKEN_URL", "https://oauth.example.test/google") + t.Setenv("MICROSOFT_OAUTH_TOKEN_URL", "https://oauth.example.test/microsoft") + withFakeCallbackTransport(t, map[string]string{ + "/google": jsonOAuthBody(t, map[string]any{"access_token": "google-token"}), + "/microsoft": jsonOAuthBody(t, map[string]any{"access_token": "microsoft-token", "id_token": idToken(t, map[string]any{"name": "No Email"})}), + }) + + db, account := setupRouterChannelCallbackDB(t) + engine := gin.New() + engine.GET("/google/callback", googleEmailCallback(db)) + engine.GET("/microsoft/callback", microsoftEmailCallback(db)) + + checks := []struct { + path string + secret string + }{ + {"/google/callback?code=google-code", "google-secret"}, + {"/microsoft/callback?code=microsoft-code", "microsoft-secret"}, + } + for _, check := range checks { + resp := performGet(engine, check.path+"&state="+url.QueryEscape(signedCallbackState(t, account.ID, check.secret))) + if resp.Code != http.StatusFound || resp.Header().Get("Location") != "https://app.example.test" { + t.Fatalf("expected frontend fallback for %s, got %d %q", check.path, resp.Code, resp.Header().Get("Location")) + } + } + var inboxCount int64 + if err := db.Model(&model.Inbox{}).Count(&inboxCount).Error; err != nil { + t.Fatalf("count inboxes: %v", err) + } + if inboxCount != 0 { + t.Fatalf("expected no inboxes after failed email OAuth callbacks, got %d", inboxCount) + } +} + func TestWellKnownRoutesServeMobileAssociationPayloads(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("ANDROID_BUNDLE_ID", "com.example.gochat") diff --git a/internal/search/engine.go b/internal/search/engine.go index 6dab89d5..1be49161 100644 --- a/internal/search/engine.go +++ b/internal/search/engine.go @@ -78,7 +78,7 @@ func (d *SearchDocument) ensureUID() { } func documentUID(docType SearchResultType, accountID uint, id uint) string { - return fmt.Sprintf("%d:%s:%d", accountID, docType, id) + return fmt.Sprintf("%d_%s_%d", accountID, docType, id) } func normalizeEngineConfig(cfg EngineConfig) EngineConfig { diff --git a/internal/search/engine_meili.go b/internal/search/engine_meili.go index ebec6a23..9ed9cf1d 100644 --- a/internal/search/engine_meili.go +++ b/internal/search/engine_meili.go @@ -85,6 +85,7 @@ func (e *MeiliSearchEngine) IndexDocument(ctx context.Context, doc SearchDocumen doc.ensureUID() resp, err := e.client.R(). SetContext(ctx). + SetQueryParam("primaryKey", "uid"). SetBody([]SearchDocument{doc}). Post(fmt.Sprintf("/indexes/%s/documents", e.indexName(doc.Type))) return meiliError(resp, err, "index document") @@ -99,6 +100,7 @@ func (e *MeiliSearchEngine) IndexBatch(ctx context.Context, docs []SearchDocumen for docType, batch := range grouped { resp, err := e.client.R(). SetContext(ctx). + SetQueryParam("primaryKey", "uid"). SetBody(batch). Post(fmt.Sprintf("/indexes/%s/documents", e.indexName(docType))) if err := meiliError(resp, err, "index batch"); err != nil { diff --git a/internal/search/engine_test.go b/internal/search/engine_test.go index b2ee35c8..792aa7b1 100644 --- a/internal/search/engine_test.go +++ b/internal/search/engine_test.go @@ -56,7 +56,7 @@ func TestDocumentBuildersSetStableUIDAndType(t *testing.T) { conv.Messages = []model.Message{{Base: model.Base{ID: 15, CreatedAt: time.Unix(1772884700, 0)}, AccountID: 3, InboxID: 7, ConversationID: 12, Content: "hello", MessageType: "incoming"}} doc := ConversationDocument(conv) - assert.Equal(t, "3:conversation:12", doc.UID) + assert.Equal(t, "3_conversation_12", doc.UID) assert.Equal(t, ResultTypeConversation, doc.Type) assert.Equal(t, uint(3), doc.AccountID) assert.Equal(t, []string{"billing", "urgent"}, doc.Labels) @@ -153,7 +153,7 @@ func TestMeiliSearchEngine_SearchSendsScopedFilter(t *testing.T) { require.Equal(t, "/indexes/gochat_contacts/search", r.URL.Path) require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody)) return jsonResponse(http.StatusOK, `{ - "hits":[{"uid":"42:contact:9","id":9,"type":"contact","account_id":42,"snippet":"Ada Lovelace","_rankingScore":0.98}], + "hits":[{"uid":"42_contact_9","id":9,"type":"contact","account_id":42,"snippet":"Ada Lovelace","_rankingScore":0.98}], "estimatedTotalHits":1 }`), nil }) @@ -373,7 +373,7 @@ func TestMeiliSearchEngine_IndexAndDeleteDocument(t *testing.T) { assert.Equal(t, []string{ "POST /indexes/gochat_contacts/documents", - "DELETE /indexes/gochat_contacts/documents/2:contact:5", + "DELETE /indexes/gochat_contacts/documents/2_contact_5", }, seen) } diff --git a/internal/service/campaign_service.go b/internal/service/campaign_service.go index 8106327e..b312f184 100644 --- a/internal/service/campaign_service.go +++ b/internal/service/campaign_service.go @@ -146,7 +146,7 @@ func (s *CampaignService) Create(ctx context.Context, accountID uint, req Create Inbox: *inbox, } - if err := s.campaignSvc.Create(ctx, c); err != nil { + if err := s.campaignRepo.Create(ctx, c); err != nil { applogger.L().Errorf("failed to create campaign: %v", err) return nil, fmt.Errorf("failed to create campaign: %w", err) } @@ -252,7 +252,7 @@ func (s *CampaignService) Delete(ctx context.Context, id, accountID uint) error // Start triggers a campaign execution. func (s *CampaignService) Start(ctx context.Context, id, accountID uint) error { - c, err := s.campaignRepo.FindByIDAndAccount(ctx, id, accountID) + c, err := s.campaignRepo.FindByDisplayIDAndAccountOrID(ctx, id, accountID) if err != nil { return fmt.Errorf("campaign not found: %w", err) } @@ -350,7 +350,7 @@ func parseCampaignScheduledAt(raw *string) (*time.Time, error) { // Stop marks a campaign as completed. func (s *CampaignService) Stop(ctx context.Context, id, accountID uint) error { - c, err := s.campaignRepo.FindByIDAndAccount(ctx, id, accountID) + c, err := s.campaignRepo.FindByDisplayIDAndAccountOrID(ctx, id, accountID) if err != nil { return fmt.Errorf("campaign not found: %w", err) } diff --git a/internal/service/captain_assistant_response_service.go b/internal/service/captain_assistant_response_service.go index 48b29619..aa697e35 100644 --- a/internal/service/captain_assistant_response_service.go +++ b/internal/service/captain_assistant_response_service.go @@ -198,7 +198,7 @@ func (s *CaptainAssistantResponseService) List(ctx context.Context, accountID ui db = db.Where("assistant_id = ?", assistantID) } if documentID > 0 { - db = db.Where("documentable_id = ? AND documentable_type = ?", documentID, "Captain::Document") + db = db.Where("documentable_id = ? AND documentable_type IN ?", documentID, []string{"Captain::Document", "CaptainDocument"}) } if status != "" { db = db.Where("status = ?", status) diff --git a/internal/service/captain_assistant_service.go b/internal/service/captain_assistant_service.go index bf874380..c9d87955 100644 --- a/internal/service/captain_assistant_service.go +++ b/internal/service/captain_assistant_service.go @@ -334,7 +334,7 @@ func (s *CaptainAssistantService) GeneratePlaygroundResponse(ctx context.Context history := playgroundMessageHistory(req.MessageHistory, req.MessageContent) content, err := s.generatePlaygroundLLMResponse(ctx, assistant, history) if err != nil { - return nil, err + return captainPlaygroundV2ErrorResponse(err), nil } return map[string]any{"response": content}, nil } @@ -347,6 +347,14 @@ func (s *CaptainAssistantService) GeneratePlaygroundResponse(ctx context.Context return map[string]any{"content": content}, nil } +func captainPlaygroundV2ErrorResponse(err error) map[string]any { + return map[string]any{ + "response": "conversation_handoff", + "reasoning": fmt.Sprintf("Error occurred: %v", err), + "handoff_tool_called": false, + } +} + func (s *CaptainAssistantService) generatePlaygroundLLMResponse(ctx context.Context, assistant *model.CaptainAssistant, history []PlaygroundMessage) (string, error) { if s.llmProvider == nil { return captainPlaygroundFallbackMessage, nil diff --git a/internal/service/captain_document_service_test.go b/internal/service/captain_document_service_test.go index 86b241b5..ec2dbb85 100644 --- a/internal/service/captain_document_service_test.go +++ b/internal/service/captain_document_service_test.go @@ -139,6 +139,14 @@ func TestCaptainDocumentService_RequestSyncQueuesDurableJob(t *testing.T) { assert.Equal(t, "fresh durable content", synced.Content) assert.Equal(t, model.DocumentSyncStatusSynced, synced.SyncStatus) assert.Empty(t, synced.LastSyncErrorCode) + + var responseBuilderJob model.BackgroundJob + require.NoError(t, db.Where("job_type = ?", TaskTypeCaptainDocumentResponseBuilder).First(&responseBuilderJob).Error) + assert.Equal(t, "low", responseBuilderJob.Queue) + assert.Equal(t, model.BackgroundJobStatusQueued, responseBuilderJob.Status) + assert.Equal(t, fmt.Sprintf("captain:document_response_builder:%d:%d", account.ID, doc.ID), responseBuilderJob.IdempotencyKey) + assert.Contains(t, string(responseBuilderJob.Payload), fmt.Sprintf("\"account_id\":%d", account.ID)) + assert.Contains(t, string(responseBuilderJob.Payload), fmt.Sprintf("\"document_id\":%d", doc.ID)) } func TestCaptainDocumentService_DocumentSyncJobRetriesMissingDocument(t *testing.T) { @@ -203,6 +211,12 @@ func TestCaptainDocumentService_RequestCrawlQueuesParserJobs(t *testing.T) { assert.Equal(t, account.ID, created.AccountID) assert.Equal(t, "FAQ", created.Name) assert.Equal(t, "answer one", created.Content) + + var responseBuilderJobs []model.BackgroundJob + require.NoError(t, db.Where("job_type = ?", TaskTypeCaptainDocumentResponseBuilder).Order("id ASC").Find(&responseBuilderJobs).Error) + require.Len(t, responseBuilderJobs, 2) + assert.Equal(t, fmt.Sprintf("captain:document_response_builder:%d:%d", account.ID, doc.ID), responseBuilderJobs[0].IdempotencyKey) + assert.Equal(t, fmt.Sprintf("captain:document_response_builder:%d:%d", account.ID, created.ID), responseBuilderJobs[1].IdempotencyKey) } func TestCaptainDocumentService_CrawlJobDisabledMarksFailed(t *testing.T) { @@ -287,6 +301,16 @@ func TestCaptainDocumentService_ResponseBuilderCreatesResponsesAndEmbeddingJobs( Status: model.ResponseStatusApproved, Edited: false, } + legacyUnedited := &model.CaptainAssistantResponse{ + AccountID: account.ID, + AssistantID: doc.AssistantID, + DocumentableID: &uneditedDocID, + DocumentableType: "CaptainDocument", + Question: "legacy old", + Answer: "legacy old answer", + Status: model.ResponseStatusApproved, + Edited: false, + } edited := &model.CaptainAssistantResponse{ AccountID: account.ID, AssistantID: doc.AssistantID, @@ -298,6 +322,7 @@ func TestCaptainDocumentService_ResponseBuilderCreatesResponsesAndEmbeddingJobs( Edited: true, } require.NoError(t, db.Create(unedited).Error) + require.NoError(t, db.Create(legacyUnedited).Error) require.NoError(t, db.Create(edited).Error) svc.SetFAQBackend(&captainDocumentFakeFAQBackend{faqs: []CaptainDocumentFAQ{ {Question: "How do refunds work?", Answer: "Refunds take five days."}, @@ -315,6 +340,8 @@ func TestCaptainDocumentService_ResponseBuilderCreatesResponsesAndEmbeddingJobs( var deleted model.CaptainAssistantResponse assert.Error(t, db.First(&deleted, unedited.ID).Error) + var legacyDeleted model.CaptainAssistantResponse + assert.Error(t, db.First(&legacyDeleted, legacyUnedited.ID).Error) var kept model.CaptainAssistantResponse require.NoError(t, db.First(&kept, edited.ID).Error) assert.True(t, kept.Edited) diff --git a/internal/service/conversation_maintenance_worker.go b/internal/service/conversation_maintenance_worker.go index 4ea6350d..2975655b 100644 --- a/internal/service/conversation_maintenance_worker.go +++ b/internal/service/conversation_maintenance_worker.go @@ -9,6 +9,7 @@ import ( "time" "github.com/gochat/gochat/internal/campaign" + "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/worker" "gorm.io/gorm" @@ -89,8 +90,8 @@ var conversationMaintenanceRegistrations sync.Map // RegisterConversationMaintenanceJobs wires Chatwoot scheduled maintenance jobs // into the durable worker: TriggerScheduledItemsJob fans out to campaign, // snooze-reopen, and auto-resolution jobs. -func RegisterConversationMaintenanceJobs(wp *worker.WorkerPool, db *gorm.DB) { - registerConversationMaintenanceJobsWithNow(wp, db, time.Now) +func RegisterConversationMaintenanceJobs(wp *worker.WorkerPool, db *gorm.DB, dispatchers ...*channel.Dispatcher) { + registerConversationMaintenanceJobsWithNow(wp, db, time.Now, dispatchers...) } func RegisterContactBulkActionSearchIndexer(wp *worker.WorkerPool, db *gorm.DB, indexer SearchIndexer) { @@ -119,11 +120,15 @@ func RegisterConversationMaintenanceSearchIndexer(wp *worker.WorkerPool, db *gor wp.Register(TaskTypeContactBulkAction, runner.performContactBulkAction) } -func registerConversationMaintenanceJobsWithNow(wp *worker.WorkerPool, db *gorm.DB, now func() time.Time) { +func registerConversationMaintenanceJobsWithNow(wp *worker.WorkerPool, db *gorm.DB, now func() time.Time, dispatchers ...*channel.Dispatcher) { if wp == nil || db == nil { return } - runner := &conversationMaintenanceRunner{wp: wp, db: db, now: now} + var dispatcher *channel.Dispatcher + if len(dispatchers) > 0 { + dispatcher = dispatchers[0] + } + runner := &conversationMaintenanceRunner{wp: wp, db: db, now: now, dispatcher: dispatcher} if _, loaded := conversationMaintenanceRegistrations.LoadOrStore(wp, runner); loaded { return } @@ -200,6 +205,7 @@ type conversationMaintenanceRunner struct { db *gorm.DB now func() time.Time searchIndexer SearchIndexer + dispatcher *channel.Dispatcher } func (r *conversationMaintenanceRunner) performScheduledTriggerItems(ctx context.Context, job *model.BackgroundJob) error { @@ -256,7 +262,7 @@ func (r *conversationMaintenanceRunner) performCampaignTriggerOneoff(ctx context if !claimed { return nil } - if err := campaign.NewCampaignService(r.db).TriggerCampaign(ctx, payload.CampaignID); err != nil { + if err := campaign.NewCampaignService(r.db, r.dispatcher).TriggerCampaign(ctx, payload.CampaignID); err != nil { _ = r.db.WithContext(ctx).Model(&campaign.Campaign{}).Where("id = ?", payload.CampaignID).Update("campaign_status", campaign.CampaignStatusActive).Error return err } diff --git a/internal/service/conversation_maintenance_worker_test.go b/internal/service/conversation_maintenance_worker_test.go index 8f99ae5c..8a2b7df8 100644 --- a/internal/service/conversation_maintenance_worker_test.go +++ b/internal/service/conversation_maintenance_worker_test.go @@ -2,12 +2,14 @@ package service import ( "context" + "encoding/json" "fmt" "strings" "testing" "time" "github.com/gochat/gochat/internal/campaign" + "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/worker" "gorm.io/gorm" @@ -46,9 +48,19 @@ func TestConversationMaintenanceJobsTriggerScheduledItemsFanOut(t *testing.T) { if err := db.Where("job_type = ?", TaskTypeCampaignTriggerOneoff).First(&campaignJob).Error; err != nil { t.Fatalf("load campaign job: %v", err) } + if campaignJob.Queue != "low" || campaignJob.Status != model.BackgroundJobStatusQueued || campaignJob.MaxAttempts != 3 { + t.Fatalf("expected queued low-priority campaign trigger job, got queue=%s status=%s max_attempts=%d", campaignJob.Queue, campaignJob.Status, campaignJob.MaxAttempts) + } if want := fmt.Sprintf("campaign:trigger_oneoff:%d", dueCampaign.ID); campaignJob.IdempotencyKey != want { t.Fatalf("expected due campaign idempotency key %q, got %q", want, campaignJob.IdempotencyKey) } + var campaignPayload campaignTriggerOneoffJob + if err := json.Unmarshal(campaignJob.Payload, &campaignPayload); err != nil { + t.Fatalf("unmarshal campaign trigger payload: %v", err) + } + if campaignPayload.CampaignID != dueCampaign.ID { + t.Fatalf("expected campaign trigger payload campaign_id=%d, got %d", dueCampaign.ID, campaignPayload.CampaignID) + } var nextTrigger model.BackgroundJob if err := db.Where("job_type = ? AND status = ?", TaskTypeScheduledTriggerItems, model.BackgroundJobStatusQueued).First(&nextTrigger).Error; err != nil { @@ -59,6 +71,29 @@ func TestConversationMaintenanceJobsTriggerScheduledItemsFanOut(t *testing.T) { } } +type conversationMaintenanceRecordingListener struct { + events []*channel.ChannelEvent +} + +func (l *conversationMaintenanceRecordingListener) Name() string { + return "conversation_maintenance_recording_listener" +} + +func (l *conversationMaintenanceRecordingListener) OnEvent(_ context.Context, event *channel.ChannelEvent) error { + l.events = append(l.events, event) + return nil +} + +func (l *conversationMaintenanceRecordingListener) eventsByType(eventType channel.EventType) []*channel.ChannelEvent { + var events []*channel.ChannelEvent + for _, event := range l.events { + if event.Type == eventType { + events = append(events, event) + } + } + return events +} + func TestConversationMaintenanceJobsProcessCampaignSnoozeAndResolution(t *testing.T) { now := time.Date(2026, 6, 5, 20, 0, 0, 0, time.UTC) db := setupServiceTestDB(t) @@ -115,6 +150,13 @@ func TestConversationMaintenanceJobsProcessCampaignSnoozeAndResolution(t *testin if completed.CampaignStatus != campaign.CampaignStatusCompleted { t.Fatalf("expected completed campaign, got %s", completed.CampaignStatus) } + var completedCampaignJob model.BackgroundJob + if err := db.Where("job_type = ? AND json_extract(payload, '$.campaign_id') = ?", TaskTypeCampaignTriggerOneoff, dueCampaign.ID).First(&completedCampaignJob).Error; err != nil { + t.Fatalf("load completed campaign trigger job: %v", err) + } + if completedCampaignJob.Status != model.BackgroundJobStatusCompleted || completedCampaignJob.FinishedAt == nil || completedCampaignJob.LastError != "" { + t.Fatalf("expected completed campaign trigger job, got status=%s finished=%v last_error=%q", completedCampaignJob.Status, completedCampaignJob.FinishedAt, completedCampaignJob.LastError) + } var campaignMessages int64 if err := db.Model(&model.Message{}).Where("content = ?", dueCampaign.Message).Count(&campaignMessages).Error; err != nil { t.Fatalf("count campaign messages: %v", err) @@ -158,6 +200,53 @@ func TestConversationMaintenanceJobsProcessCampaignSnoozeAndResolution(t *testin } } +func TestConversationMaintenanceJobsCampaignTriggerDispatchesEvents(t *testing.T) { + now := time.Date(2026, 6, 5, 20, 15, 0, 0, time.UTC) + db := setupServiceTestDB(t) + if err := db.AutoMigrate(&campaign.Campaign{}); err != nil { + t.Fatalf("migrate campaign: %v", err) + } + wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return now })) + dispatcher := channel.NewDispatcher() + listener := &conversationMaintenanceRecordingListener{} + dispatcher.Register(listener) + registerConversationMaintenanceJobsWithNow(wp, db, func() time.Time { return now }, dispatcher) + + account := createTestAccount(t, db) + inbox := createTestInbox(t, db, account.ID, "sms") + contact := createTestContact(t, db, account.ID) + dueCampaign := createTestOneoffCampaign(t, db, account.ID, inbox.ID, contact.ID, now.Add(-time.Hour)) + if _, err := wp.Enqueue(context.Background(), TaskTypeCampaignTriggerOneoff, campaignTriggerOneoffJob{CampaignID: dueCampaign.ID}, worker.WithQueue("low")); err != nil { + t.Fatalf("enqueue campaign: %v", err) + } + processRequiredJob(t, wp, "campaign realtime dispatch") + + conversationEvents := listener.eventsByType(channel.EventConversationCreated) + if len(conversationEvents) != 1 { + t.Fatalf("expected one conversation.created event, got %#v", listener.events) + } + conversationEvent := conversationEvents[0] + if conversationEvent.AccountID != account.ID || conversationEvent.InboxID != inbox.ID || conversationEvent.ContactID != contact.ID || conversationEvent.Data["campaign_id"] != dueCampaign.ID { + t.Fatalf("unexpected conversation.created campaign event: %#v", conversationEvent) + } + if _, ok := conversationEvent.Data["conversation"].(*model.Conversation); !ok { + t.Fatalf("expected conversation payload, got %#v", conversationEvent.Data["conversation"]) + } + + messageEvents := listener.eventsByType(channel.EventMessageCreated) + if len(messageEvents) != 1 { + t.Fatalf("expected one message.created event, got %#v", listener.events) + } + messageEvent := messageEvents[0] + message, ok := messageEvent.Data["message"].(*model.Message) + if !ok || message.Content != dueCampaign.Message || message.MessageType != "outgoing" { + t.Fatalf("unexpected message.created payload: %#v", messageEvent.Data["message"]) + } + if len(listener.eventsByType(channel.EventConversationOpened)) != 1 || len(listener.eventsByType(channel.EventMessageOutgoing)) != 1 { + t.Fatalf("expected opened and outgoing campaign events, got %#v", listener.events) + } +} + func TestConversationMaintenanceJobsStatusMaintenanceQueuesSearchIndex(t *testing.T) { now := time.Date(2026, 6, 5, 20, 30, 0, 0, time.UTC) db := setupServiceTestDB(t) @@ -428,6 +517,14 @@ func TestConversationMaintenanceJobsConversationBulkActionQueuesSearchIndex(t *t if indexed.Contact == nil || indexed.Inbox == nil { t.Fatalf("expected indexed conversation relations, got contact=%#v inbox=%#v", indexed.Contact, indexed.Inbox) } + + var bulkJob model.BackgroundJob + if err := db.Where("job_type = ?", TaskTypeConversationBulkAction).First(&bulkJob).Error; err != nil { + t.Fatalf("load completed conversation bulk action job: %v", err) + } + if bulkJob.Status != model.BackgroundJobStatusCompleted || bulkJob.FinishedAt == nil || bulkJob.LastError != "" { + t.Fatalf("expected completed conversation bulk action job, got status=%s finished=%v last_error=%q", bulkJob.Status, bulkJob.FinishedAt, bulkJob.LastError) + } } func TestConversationMaintenanceJobsContactBulkAction(t *testing.T) { @@ -499,6 +596,41 @@ func TestConversationMaintenanceJobsContactBulkAction(t *testing.T) { } } +func TestConversationMaintenanceJobsContactBulkActionQueuesSearchIndexAndCompletes(t *testing.T) { + now := time.Date(2026, 6, 6, 0, 40, 0, 0, time.UTC) + db := setupServiceTestDB(t) + wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return now })) + registerConversationMaintenanceJobsWithNow(wp, db, func() time.Time { return now }) + delegate := &recordingDurableSearchIndexer{} + searchIndexer := NewDurableSearchIndexer(db, wp, delegate) + RegisterContactBulkActionSearchIndexer(wp, db, searchIndexer) + + account := createTestAccount(t, db) + contact := createTestContact(t, db, account.ID) + if _, err := EnqueueContactBulkAction(context.Background(), wp, account.ID, 42, ContactBulkActionParams{ + Type: "Contact", + IDs: []uint{contact.ID}, + Labels: ConversationBulkActionLabels{Add: []string{"vip"}}, + }); err != nil { + t.Fatalf("enqueue contact bulk action: %v", err) + } + processRequiredJob(t, wp, "contact bulk add labels") + processRequiredJob(t, wp, "contact search index") + + if len(delegate.indexedContacts) != 1 || delegate.indexedContacts[0] != contact.ID { + t.Fatalf("expected indexed contact %d, got %#v", contact.ID, delegate.indexedContacts) + } + assertContactHasLabel(t, db, account.ID, contact.ID, "vip", true) + + var bulkJob model.BackgroundJob + if err := db.Where("job_type = ?", TaskTypeContactBulkAction).First(&bulkJob).Error; err != nil { + t.Fatalf("load completed contact bulk action job: %v", err) + } + if bulkJob.Status != model.BackgroundJobStatusCompleted || bulkJob.FinishedAt == nil || bulkJob.LastError != "" { + t.Fatalf("expected completed contact bulk action job, got status=%s finished=%v last_error=%q", bulkJob.Status, bulkJob.FinishedAt, bulkJob.LastError) + } +} + func TestConversationMaintenanceJobsContactBulkActionQueuesSearchIndex(t *testing.T) { now := time.Date(2026, 6, 6, 0, 30, 0, 0, time.UTC) db := setupServiceTestDB(t) diff --git a/internal/service/conversation_participant_service.go b/internal/service/conversation_participant_service.go index 0787bb4a..55dde434 100644 --- a/internal/service/conversation_participant_service.go +++ b/internal/service/conversation_participant_service.go @@ -28,13 +28,18 @@ func (s *ConversationParticipantService) SetAssignableAgentService(svc *Assignab s.assignableAgentSvc = svc } +func (s *ConversationParticipantService) resolveConversationForRoute(ctx context.Context, accountID, routeID uint) (*model.Conversation, error) { + return s.conversationRepo.FindByAccountAndDisplayIDOrID(ctx, accountID, routeID) +} + // List retrieves all participants for a conversation. // Chatwoot: @participants = @conversation.conversation_participants func (s *ConversationParticipantService) List(ctx context.Context, accountID, conversationID uint) ([]model.ConversationParticipant, error) { - if _, err := s.conversationRepo.FindByAccountAndID(ctx, accountID, conversationID); err != nil { + conversation, err := s.resolveConversationForRoute(ctx, accountID, conversationID) + if err != nil { return nil, err } - return s.repo.FindByConversationID(ctx, conversationID) + return s.repo.FindByConversationID(ctx, conversation.ID) } // Add adds a participant to a conversation. @@ -47,18 +52,22 @@ func (s *ConversationParticipantService) Add(ctx context.Context, accountID, con if len(participants) > 0 { return &participants[0], nil } - return s.repo.FindByConversationAndUserID(ctx, conversationID, userID) + conversation, err := s.resolveConversationForRoute(ctx, accountID, conversationID) + if err != nil { + return nil, err + } + return s.repo.FindByConversationAndUserID(ctx, conversation.ID, userID) } // AddMany adds the users missing from the participant set and returns only the newly added rows. // Chatwoot create action: participants_to_be_added_ids.map { find_or_create_by(user_id:) }. func (s *ConversationParticipantService) AddMany(ctx context.Context, accountID, conversationID uint, userIDs []uint, role string) ([]model.ConversationParticipant, error) { - conversation, err := s.conversationRepo.FindByAccountAndID(ctx, accountID, conversationID) + conversation, err := s.resolveConversationForRoute(ctx, accountID, conversationID) if err != nil { return nil, err } - current, err := s.repo.FindByConversationID(ctx, conversationID) + current, err := s.repo.FindByConversationID(ctx, conversation.ID) if err != nil { return nil, err } @@ -76,7 +85,7 @@ func (s *ConversationParticipantService) AddMany(ctx context.Context, accountID, } participant := &model.ConversationParticipant{ AccountID: accountID, - ConversationID: conversationID, + ConversationID: conversation.ID, UserID: userID, Role: role, } @@ -84,7 +93,7 @@ func (s *ConversationParticipantService) AddMany(ctx context.Context, accountID, applogger.L().Errorf("ConversationParticipantService.AddMany: %v", err) return nil, err } - reloaded, err := s.repo.FindByConversationAndUserID(ctx, conversationID, userID) + reloaded, err := s.repo.FindByConversationAndUserID(ctx, conversation.ID, userID) if err != nil { return nil, err } @@ -96,31 +105,34 @@ func (s *ConversationParticipantService) AddMany(ctx context.Context, accountID, // Update updates a participant's role in a conversation. func (s *ConversationParticipantService) Update(ctx context.Context, accountID, conversationID, userID uint, role string) (*model.ConversationParticipant, error) { - if _, err := s.conversationRepo.FindByAccountAndID(ctx, accountID, conversationID); err != nil { + conversation, err := s.resolveConversationForRoute(ctx, accountID, conversationID) + if err != nil { return nil, err } - if err := s.repo.UpdateRole(ctx, conversationID, userID, role); err != nil { + if err := s.repo.UpdateRole(ctx, conversation.ID, userID, role); err != nil { applogger.L().Errorf("ConversationParticipantService.Update: %v", err) return nil, err } - return s.repo.FindByConversationAndUserID(ctx, conversationID, userID) + return s.repo.FindByConversationAndUserID(ctx, conversation.ID, userID) } // Remove removes a participant from a conversation. // Chatwoot: conversation_participants.find_by(user_id: user_id)&.destroy func (s *ConversationParticipantService) Remove(ctx context.Context, accountID, conversationID, userID uint) error { - if _, err := s.conversationRepo.FindByAccountAndID(ctx, accountID, conversationID); err != nil { + conversation, err := s.resolveConversationForRoute(ctx, accountID, conversationID) + if err != nil { return err } - return s.repo.DeleteByConversationAndUserID(ctx, conversationID, userID) + return s.repo.DeleteByConversationAndUserID(ctx, conversation.ID, userID) } // RemoveMany removes multiple participants. Missing users are ignored, matching Chatwoot destroy. func (s *ConversationParticipantService) RemoveMany(ctx context.Context, accountID, conversationID uint, userIDs []uint) error { - if _, err := s.conversationRepo.FindByAccountAndID(ctx, accountID, conversationID); err != nil { + conversation, err := s.resolveConversationForRoute(ctx, accountID, conversationID) + if err != nil { return err } - return s.repo.BatchDelete(ctx, conversationID, uniqueParticipantUserIDs(userIDs)) + return s.repo.BatchDelete(ctx, conversation.ID, uniqueParticipantUserIDs(userIDs)) } // BatchUpdate adds and removes participants in bulk. @@ -130,7 +142,7 @@ func (s *ConversationParticipantService) BatchUpdate(ctx context.Context, accoun return s.Replace(ctx, accountID, conversationID, addUserIDs, role) } - conversation, err := s.conversationRepo.FindByAccountAndID(ctx, accountID, conversationID) + conversation, err := s.resolveConversationForRoute(ctx, accountID, conversationID) if err != nil { return nil, err } @@ -142,13 +154,13 @@ func (s *ConversationParticipantService) BatchUpdate(ctx context.Context, accoun return nil, err } } - existing, _ := s.repo.FindByConversationAndUserID(ctx, conversationID, userID) + existing, _ := s.repo.FindByConversationAndUserID(ctx, conversation.ID, userID) if existing != nil { continue } participant := &model.ConversationParticipant{ AccountID: accountID, - ConversationID: conversationID, + ConversationID: conversation.ID, UserID: userID, Role: role, } @@ -159,23 +171,23 @@ func (s *ConversationParticipantService) BatchUpdate(ctx context.Context, accoun // Remove old participants for _, userID := range removeUserIDs { - if err := s.repo.DeleteByConversationAndUserID(ctx, conversationID, userID); err != nil { + if err := s.repo.DeleteByConversationAndUserID(ctx, conversation.ID, userID); err != nil { applogger.L().Errorf("BatchUpdate remove user %d: %v", userID, err) } } - return s.repo.FindByConversationID(ctx, conversationID) + return s.repo.FindByConversationID(ctx, conversation.ID) } // Replace syncs participants to the supplied final user ID set. // Chatwoot update action: add ids not currently present and remove ids absent from params[:user_ids]. func (s *ConversationParticipantService) Replace(ctx context.Context, accountID, conversationID uint, userIDs []uint, role string) ([]model.ConversationParticipant, error) { - conversation, err := s.conversationRepo.FindByAccountAndID(ctx, accountID, conversationID) + conversation, err := s.resolveConversationForRoute(ctx, accountID, conversationID) if err != nil { return nil, err } - current, err := s.repo.FindByConversationID(ctx, conversationID) + current, err := s.repo.FindByConversationID(ctx, conversation.ID) if err != nil { return nil, err } @@ -193,7 +205,7 @@ func (s *ConversationParticipantService) Replace(ctx context.Context, accountID, } participant := &model.ConversationParticipant{ AccountID: accountID, - ConversationID: conversationID, + ConversationID: conversation.ID, UserID: userID, Role: role, } @@ -209,11 +221,11 @@ func (s *ConversationParticipantService) Replace(ctx context.Context, accountID, removeIDs = append(removeIDs, userID) } } - if err := s.repo.BatchDelete(ctx, conversationID, removeIDs); err != nil { + if err := s.repo.BatchDelete(ctx, conversation.ID, removeIDs); err != nil { return nil, err } - return s.repo.FindByConversationID(ctx, conversationID) + return s.repo.FindByConversationID(ctx, conversation.ID) } func uniqueParticipantUserIDs(ids []uint) []uint { diff --git a/internal/service/conversation_service_test.go b/internal/service/conversation_service_test.go index 2a3a8cc2..696623e0 100644 --- a/internal/service/conversation_service_test.go +++ b/internal/service/conversation_service_test.go @@ -1015,3 +1015,104 @@ func TestConversationService_UnreadCounts_CustomRoleParticipatingScope(t *testin require.NoError(t, err) assert.Equal(t, int64(1), payload.Inboxes[inbox.ID]) } + +func TestConversationService_MutationEventsCarryChatwootChangeData(t *testing.T) { + db := setupConversationServiceTestDB(t) + convRepo := repository.NewConversationRepo(db) + msgRepo := repository.NewMessageRepo(db) + dispatcher := channel.NewDispatcher() + listener := &captureConversationEventsListener{} + dispatcher.Register(listener) + inboxMemberSvc := NewInboxMemberService(repository.NewInboxMemberRepo(db)) + accountUserRepo := repository.NewAccountUserRepo(db) + teamRepo := repository.NewTeamRepo(db) + teamMemberRepo := repository.NewTeamMemberRepo(db) + svc := NewConversationService(convRepo, msgRepo, dispatcher, inboxMemberSvc, accountUserRepo, teamRepo, teamMemberRepo) + + ctx := context.Background() + account := createConversationServiceTestAccount(t, db) + inbox := createConversationServiceTestInbox(t, db, account.ID) + contact := createConversationServiceTestContact(t, db, account.ID) + conversation := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, string(model.ConversationStatusOpen)) + user := &model.User{Name: "Realtime Agent", Email: "realtime-agent@example.com"} + require.NoError(t, db.Create(user).Error) + require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "agent"}).Error) + require.NoError(t, db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: user.ID}).Error) + team := &model.Team{AccountID: account.ID, Name: "Realtime Team", AllowAutoAssignment: true} + require.NoError(t, db.Create(team).Error) + + _, err := svc.ToggleStatus(ctx, account.ID, conversation.ID, ToggleStatusRequest{Status: string(model.ConversationStatusResolved)}) + require.NoError(t, err) + statusEvent := listener.events[len(listener.events)-1] + assert.Equal(t, channel.EventConversationResolved, statusEvent.Type) + assert.Equal(t, account.ID, statusEvent.AccountID) + assert.Equal(t, inbox.ID, statusEvent.InboxID) + assert.Equal(t, conversation.ID, statusEvent.ConversationID) + statusChanges := statusEvent.Data["changed_attributes"].(map[string]interface{}) + assert.Equal(t, map[string]interface{}{"from": "open", "to": "resolved"}, statusChanges["status"]) + assert.Equal(t, conversation.ID, statusEvent.Data["conversation"].(*model.Conversation).ID) + + _, err = svc.UpdatePriority(ctx, account.ID, conversation.ID, string(model.ConversationPriorityUrgent)) + require.NoError(t, err) + priorityEvent := listener.events[len(listener.events)-1] + assert.Equal(t, channel.EventConversationPriorityUpdated, priorityEvent.Type) + priorityChanges := priorityEvent.Data["changed_attributes"].(map[string]interface{}) + assert.Equal(t, map[string]interface{}{"from": "none", "to": "urgent"}, priorityChanges["priority"]) + + _, err = svc.UpdateLabels(ctx, account.ID, conversation.ID, []string{"support", "urgent"}) + require.NoError(t, err) + labelsEvent := listener.events[len(listener.events)-1] + assert.Equal(t, channel.EventConversationLabelsUpdated, labelsEvent.Type) + assert.Equal(t, "support,urgent", labelsEvent.Data["conversation"].(*model.Conversation).Labels) + + _, err = svc.AssignTeam(ctx, account.ID, conversation.ID, &user.ID, &team.ID) + require.NoError(t, err) + assignmentEvent := listener.events[len(listener.events)-1] + assert.Equal(t, channel.EventConversationAssigned, assignmentEvent.Type) + assert.Equal(t, user.ID, assignmentEvent.UserID) + assignedConversation := assignmentEvent.Data["conversation"].(*model.Conversation) + require.NotNil(t, assignedConversation.AssigneeID) + require.NotNil(t, assignedConversation.TeamID) + assert.Equal(t, user.ID, *assignedConversation.AssigneeID) + assert.Equal(t, team.ID, *assignedConversation.TeamID) +} + +func TestConversationService_ToggleTypingDispatchesChatwootPayload(t *testing.T) { + db := setupConversationServiceTestDB(t) + convRepo := repository.NewConversationRepo(db) + msgRepo := repository.NewMessageRepo(db) + dispatcher := channel.NewDispatcher() + listener := &captureConversationEventsListener{} + dispatcher.Register(listener) + svc := NewConversationService(convRepo, msgRepo, dispatcher, nil, nil, nil, nil) + + ctx := context.Background() + account := createConversationServiceTestAccount(t, db) + inbox := createConversationServiceTestInbox(t, db, account.ID) + contact := createConversationServiceTestContact(t, db, account.ID) + conversation := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, string(model.ConversationStatusOpen)) + user := &model.User{Name: "Typing Agent", Email: "typing-agent@example.com"} + require.NoError(t, db.Create(user).Error) + + require.NoError(t, svc.ToggleTyping(ctx, account.ID, conversation.ID, user.ID, "on", true)) + require.Len(t, listener.events, 1) + typingOn := listener.events[0] + assert.Equal(t, channel.EventConversationTypingOn, typingOn.Type) + assert.Equal(t, account.ID, typingOn.AccountID) + assert.Equal(t, inbox.ID, typingOn.InboxID) + assert.Equal(t, conversation.ID, typingOn.ConversationID) + assert.Equal(t, contact.ID, typingOn.ContactID) + assert.Equal(t, user.ID, typingOn.UserID) + assert.Equal(t, "on", typingOn.Data["typing_status"]) + assert.Equal(t, true, typingOn.Data["is_private"]) + + require.NoError(t, svc.ToggleTyping(ctx, account.ID, conversation.ID, user.ID, "typing_off", false)) + require.Len(t, listener.events, 2) + typingOff := listener.events[1] + assert.Equal(t, channel.EventConversationTypingOff, typingOff.Type) + assert.Equal(t, "typing_off", typingOff.Data["typing_status"]) + assert.Equal(t, false, typingOff.Data["is_private"]) + + require.NoError(t, svc.ToggleTyping(ctx, account.ID, conversation.ID, user.ID, "", false)) + assert.Len(t, listener.events, 2) +} diff --git a/internal/service/copilot_response_worker_test.go b/internal/service/copilot_response_worker_test.go index 29ceec81..95ff0386 100644 --- a/internal/service/copilot_response_worker_test.go +++ b/internal/service/copilot_response_worker_test.go @@ -81,6 +81,63 @@ func TestCopilotResponseJobUsesDisabledFallbackWithoutProvider(t *testing.T) { assert.Equal(t, CopilotUnavailableMessage, assistantMsg.GetMessageContent()) } +func TestCopilotResponseJobForFollowupMessageUsesStoredThreadHistoryAndIdempotency(t *testing.T) { + db, svc, account, user, assistant := setupCopilotResponseWorkerTest(t) + backend := &fakeCopilotResponseBackend{messages: []CopilotGeneratedMessage{{MessageType: model.CopilotMessageTypeAssistant, Message: map[string]any{"content": "Background answer"}}}} + svc.SetResponseBackend(backend) + wp := worker.NewWorkerPool(db) + svc.SetWorkerPool(wp) + + thread, err := svc.CreateThread(context.Background(), account.ID, user.ID, &CreateThreadRequest{Message: "Need help", AssistantID: assistant.ID, ConversationID: 123}) + require.NoError(t, err) + processed, err := wp.ProcessOne(context.Background()) + require.NoError(t, err) + require.True(t, processed) + + result, err := svc.SendMessage(context.Background(), account.ID, user.ID, thread.ID, &SendMessageRequest{Message: "Follow up", ConversationID: 321}) + require.NoError(t, err) + require.NotNil(t, result.UserMessage) + require.Nil(t, result.AssistantMessage) + + var queued model.BackgroundJob + require.NoError(t, db.Where("job_type = ? AND status = ?", TaskTypeCaptainCopilotResponse, model.BackgroundJobStatusQueued).First(&queued).Error) + assert.Equal(t, fmt.Sprintf("captain:copilot_response:%d", result.UserMessage.ID), queued.IdempotencyKey) + assert.Equal(t, 3, queued.MaxAttempts) + var payload captainCopilotResponseJob + require.NoError(t, json.Unmarshal(queued.Payload, &payload)) + assert.Equal(t, account.ID, payload.AccountID) + assert.Equal(t, user.ID, payload.UserID) + assert.Equal(t, thread.ID, payload.CopilotThreadID) + assert.Equal(t, result.UserMessage.ID, payload.MessageID) + assert.Equal(t, uint(321), payload.ConversationID) + assert.Equal(t, "Follow up", payload.Message) + + processed, err = wp.ProcessOne(context.Background()) + require.NoError(t, err) + require.True(t, processed) + require.Len(t, backend.requests, 2) + secondRequest := backend.requests[1] + assert.Equal(t, "Follow up", secondRequest.Message) + assert.Equal(t, uint(321), secondRequest.ConversationID) + require.NotNil(t, secondRequest.Thread) + require.Len(t, secondRequest.Thread.Messages, 3) + history := secondRequest.Thread.PreviousHistory(secondRequest.Thread.Messages) + require.Len(t, history, 3) + assert.Equal(t, "Need help", history[0].Content) + assert.Equal(t, "Background answer", history[1].Content) + assert.Equal(t, "Follow up", history[2].Content) + + var messages []model.CopilotMessage + require.NoError(t, db.Where("copilot_thread_id = ?", thread.ID).Order("id ASC").Find(&messages).Error) + require.Len(t, messages, 4) + assert.Equal(t, model.CopilotMessageTypeUser, messages[0].MessageType) + assert.Equal(t, model.CopilotMessageTypeAssistant, messages[1].MessageType) + assert.Equal(t, model.CopilotMessageTypeUser, messages[2].MessageType) + assert.Equal(t, model.CopilotMessageTypeAssistant, messages[3].MessageType) + assert.Equal(t, "Follow up", messages[2].GetMessageContent()) + assert.Equal(t, "Background answer", messages[3].GetMessageContent()) +} + func TestCopilotResponseJobRetriesBackendFailure(t *testing.T) { db, svc, account, user, assistant := setupCopilotResponseWorkerTest(t) svc.SetResponseBackend(&fakeCopilotResponseBackend{err: errors.New("backend down")}) @@ -103,9 +160,11 @@ func TestCopilotResponseJobRetriesBackendFailure(t *testing.T) { type fakeCopilotResponseBackend struct { messages []CopilotGeneratedMessage err error + requests []CopilotResponseRequest } func (b *fakeCopilotResponseBackend) GenerateCopilotResponse(ctx context.Context, req CopilotResponseRequest) ([]CopilotGeneratedMessage, error) { + b.requests = append(b.requests, req) if b.err != nil { return nil, b.err } diff --git a/internal/service/draft_message_service.go b/internal/service/draft_message_service.go index 4dc168b1..6197eb95 100644 --- a/internal/service/draft_message_service.go +++ b/internal/service/draft_message_service.go @@ -29,11 +29,11 @@ func (s *DraftMessageService) Ready() bool { // List retrieves all draft messages for a conversation, optionally filtered by user. func (s *DraftMessageService) List(ctx context.Context, accountID, conversationID, userID uint) ([]model.DraftMessage, error) { - // Verify conversation belongs to the account - if _, err := s.conversationRepo.FindByAccountAndID(ctx, accountID, conversationID); err != nil { + conversation, err := s.conversationRepo.FindByAccountAndDisplayIDOrID(ctx, accountID, conversationID) + if err != nil { return nil, err } - return s.repo.FindByConversationID(ctx, conversationID, userID) + return s.repo.FindByConversationID(ctx, conversation.ID, userID) } // ShowConversationDraft retrieves Chatwoot's conversation-scoped draft message. @@ -81,13 +81,13 @@ func (s *DraftMessageService) DeleteConversationDraft(ctx context.Context, accou // Create creates a new draft message. func (s *DraftMessageService) Create(ctx context.Context, accountID, conversationID, userID uint, content string) (*model.DraftMessage, error) { - // Verify conversation belongs to the account - if _, err := s.conversationRepo.FindByAccountAndID(ctx, accountID, conversationID); err != nil { + conversation, err := s.conversationRepo.FindByAccountAndDisplayIDOrID(ctx, accountID, conversationID) + if err != nil { return nil, err } draft := &model.DraftMessage{ - ConversationID: conversationID, + ConversationID: conversation.ID, UserID: userID, Content: content, } diff --git a/internal/service/inbox_service.go b/internal/service/inbox_service.go index 6d10e1a3..d3a5bac0 100644 --- a/internal/service/inbox_service.go +++ b/internal/service/inbox_service.go @@ -820,22 +820,23 @@ func (s *InboxService) DeleteByAccount(ctx context.Context, accountID, id uint) // Reference: Chatwoot app/models/channel/web_widget.rb — stores widget_token, hmac_token, // widget_color, welcome_title, welcome_tagline, greeting_enabled, etc. type WebWidgetConfig struct { - WebsiteToken string `json:"website_token"` - HMACToken string `json:"hmac_token"` - WidgetColor string `json:"widget_color,omitempty"` - WelcomeTitle string `json:"welcome_title,omitempty"` - WelcomeTagline string `json:"welcome_tagline,omitempty"` - GreetingEnabled bool `json:"greeting_enabled,omitempty"` - GreetingMessage string `json:"greeting_message,omitempty"` - ReplyTime string `json:"reply_time,omitempty"` // "a_few_minutes", "a_few_hours", "in_a_day" - PreChatMessage string `json:"pre_chat_message,omitempty"` - PreChatFieldsEnabled bool `json:"pre_chat_fields_enabled,omitempty"` - AutoAssignmentEnabled bool `json:"auto_assignment_enabled,omitempty"` - ContinuityViaEmail bool `json:"continuity_via_email,omitempty"` - OfflineMessageEnabled bool `json:"offline_message_enabled,omitempty"` // M11: Allow offline messages - OfflineMessageTitle string `json:"offline_message_title,omitempty"` // M11: Offline form title - OfflineMessageDesc string `json:"offline_message_description,omitempty"` // M11: Offline form description - HMACMandatory bool `json:"hmac_mandatory,omitempty"` + WebsiteToken string `json:"website_token"` + HMACToken string `json:"hmac_token"` + WidgetColor string `json:"widget_color,omitempty"` + WelcomeTitle string `json:"welcome_title,omitempty"` + WelcomeTagline string `json:"welcome_tagline,omitempty"` + GreetingEnabled bool `json:"greeting_enabled,omitempty"` + GreetingMessage string `json:"greeting_message,omitempty"` + ReplyTime string `json:"reply_time,omitempty"` // "a_few_minutes", "a_few_hours", "in_a_day" + PreChatMessage string `json:"pre_chat_message,omitempty"` + PreChatFieldsEnabled bool `json:"pre_chat_fields_enabled,omitempty"` + AutoAssignmentEnabled bool `json:"auto_assignment_enabled,omitempty"` + ContinuityViaEmail bool `json:"continuity_via_email,omitempty"` + OfflineMessageEnabled bool `json:"offline_message_enabled,omitempty"` // M11: Allow offline messages + OfflineMessageTitle string `json:"offline_message_title,omitempty"` // M11: Offline form title + OfflineMessageDesc string `json:"offline_message_description,omitempty"` // M11: Offline form description + HMACMandatory bool `json:"hmac_mandatory,omitempty"` + SelectedFeatureFlags []string `json:"selected_feature_flags,omitempty"` } // CreateWebWidgetInboxRequest is the DTO for creating a web_widget inbox. diff --git a/internal/service/linear_integration_service_test.go b/internal/service/linear_integration_service_test.go new file mode 100644 index 00000000..eb41709f --- /dev/null +++ b/internal/service/linear_integration_service_test.go @@ -0,0 +1,111 @@ +package service + +import ( + "bytes" + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/gochat/gochat/internal/model" + "github.com/gochat/gochat/internal/repository" +) + +type linearRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f linearRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestLinearIntegrationService_ChatwootFrontendPayloads(t *testing.T) { + t.Setenv("FRONTEND_URL", "https://app.example.test") + svc, db, accountID := setupLinearIntegrationServiceFixture(t) + svc.client = &linearAPIClient{ + graphqlURL: "https://linear.example/graphql", + revokeURL: "https://linear.example/oauth/revoke", + httpClient: &http.Client{Transport: linearRoundTripFunc(func(req *http.Request) (*http.Response, error) { + require.Equal(t, "Bearer linear-token", req.Header.Get("Authorization")) + raw, err := io.ReadAll(req.Body) + require.NoError(t, err) + body := string(raw) + var payload string + switch { + case strings.Contains(body, "teams"): + payload = `{"data":{"teams":{"nodes":[{"id":"team-1","name":"Support"}]}}}` + case strings.Contains(body, "workflowStates"): + payload = `{"data":{"users":{"nodes":[{"id":"user-1","name":"Agent"}]},"projects":{"nodes":[{"id":"project-1","name":"Inbox"}]},"workflowStates":{"nodes":[{"id":"state-1","name":"Todo"}]},"issueLabels":{"nodes":[{"id":"label-1","name":"Bug"}]}}}` + case strings.Contains(body, "issueCreate"): + payload = `{"data":{"issueCreate":{"success":true,"issue":{"id":"issue-1","title":"Bug","identifier":"ENG-1"}}}}` + case strings.Contains(body, "attachmentLinkURL"): + payload = `{"data":{"attachmentLinkURL":{"success":true,"attachment":{"id":"link-1"}}}}` + case strings.Contains(body, "attachmentDelete"): + payload = `{"data":{"attachmentDelete":{"success":true}}}` + case strings.Contains(body, "searchIssues"): + payload = `{"data":{"searchIssues":{"nodes":[{"id":"issue-1","title":"Bug","identifier":"ENG-1","url":"https://linear.app/ENG-1","state":{"name":"Todo","color":"#eee"}}]}}}` + case strings.Contains(body, "attachmentsForURL"): + payload = `{"data":{"attachmentsForURL":{"nodes":[{"id":"link-1","title":"Bug","issue":{"id":"issue-1","identifier":"ENG-1","title":"Bug","url":"https://linear.app/ENG-1","state":{"name":"Todo","color":"#eee"},"labels":{"nodes":[{"id":"label-1","name":"Bug","color":"#f00"}]}}}]}}}` + default: + t.Fatalf("unexpected Linear GraphQL request: %s", body) + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(payload)), Header: make(http.Header)}, nil + })}, + } + require.NoError(t, db.Create(&model.IntegrationHook{AccountID: accountID, AppID: "linear", HookType: model.HookTypeLinear, Status: model.HookStatusActive, AccessToken: "linear-token"}).Error) + + teams, err := svc.GetTeams(context.Background(), accountID) + require.NoError(t, err) + require.Equal(t, "team-1", teams[0]["id"]) + require.Equal(t, "Support", teams[0]["name"]) + + entities, err := svc.GetTeamEntities(context.Background(), accountID, "team-1") + require.NoError(t, err) + require.Equal(t, "Agent", entities["users"].([]map[string]interface{})[0]["name"]) + require.Equal(t, "Todo", entities["states"].([]map[string]interface{})[0]["name"]) + + created, err := svc.CreateIssue(context.Background(), accountID, CreateIssueRequest{Title: "Bug", TeamID: "team-1", ConversationID: 42}, 1) + require.NoError(t, err) + require.Equal(t, "issue-1", created["id"]) + require.Equal(t, "ENG-1", created["identifier"]) + + linked, err := svc.LinkIssue(context.Background(), accountID, LinkIssueRequest{IssueID: "issue-1", ConversationID: 42, Title: "Bug"}, 1) + require.NoError(t, err) + require.Equal(t, "link-1", linked["link_id"]) + + unlinked, err := svc.UnlinkIssue(context.Background(), accountID, UnlinkIssueRequest{LinkID: "link-1", IssueID: "issue-1", ConversationID: 42}, 1) + require.NoError(t, err) + require.Equal(t, "link-1", unlinked["link_id"]) + + search, err := svc.SearchIssue(context.Background(), accountID, "bug") + require.NoError(t, err) + require.Equal(t, "ENG-1", search[0]["identifier"]) + + linkedIssues, err := svc.GetLinkedIssues(context.Background(), accountID, 42) + require.NoError(t, err) + require.Equal(t, "link-1", linkedIssues[0]["id"]) +} + +func setupLinearIntegrationServiceFixture(t *testing.T) (*LinearIntegrationService, *gorm.DB, uint) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:linear_service_success?mode=memory&cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + t.Cleanup(func() { sqlDB, _ := db.DB(); _ = sqlDB.Close() }) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.User{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, &model.Message{}, &model.IntegrationHook{})) + account := &model.Account{Name: "Linear Account", Locale: "en", Status: "active"} + require.NoError(t, db.Create(account).Error) + user := &model.User{AccountID: account.ID, Name: "Linear Agent", Email: "linear-agent@example.test", Role: "agent"} + require.NoError(t, db.Create(user).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "Website", ChannelType: "web_widget", Enabled: true} + require.NoError(t, db.Create(inbox).Error) + contact := &model.Contact{AccountID: account.ID, Name: "Visitor"} + require.NoError(t, db.Create(contact).Error) + displayID := uint(42) + conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"} + require.NoError(t, db.Create(conversation).Error) + return NewLinearIntegrationService(repository.NewIntegrationHookRepo(db)), db, account.ID +} diff --git a/internal/service/linear_notion_integration_service.go b/internal/service/linear_notion_integration_service.go index 91cf4a23..869fcdd2 100644 --- a/internal/service/linear_notion_integration_service.go +++ b/internal/service/linear_notion_integration_service.go @@ -49,8 +49,10 @@ type LinearIntegrationService struct { client *linearAPIClient } +type LinearIntegrationOption func(*LinearIntegrationService) + // NewLinearIntegrationService creates a new LinearIntegrationService. -func NewLinearIntegrationService(hookRepo *repository.IntegrationHookRepo) *LinearIntegrationService { +func NewLinearIntegrationService(hookRepo *repository.IntegrationHookRepo, options ...LinearIntegrationOption) *LinearIntegrationService { svc := &LinearIntegrationService{hookRepo: hookRepo, client: newLinearAPIClientFromEnv()} if hookRepo != nil && hookRepo.DB() != nil { db := hookRepo.DB() @@ -58,9 +60,26 @@ func NewLinearIntegrationService(hookRepo *repository.IntegrationHookRepo) *Line svc.messageRepo = repository.NewMessageRepo(db) svc.userRepo = repository.NewUserRepo(db) } + for _, option := range options { + option(svc) + } return svc } +func WithLinearHTTPClient(baseURL string, httpClient *http.Client) LinearIntegrationOption { + return func(svc *LinearIntegrationService) { + if httpClient == nil { + return + } + baseURL = strings.TrimRight(baseURL, "/") + svc.client = &linearAPIClient{ + graphqlURL: baseURL + "/graphql", + revokeURL: baseURL + "/oauth/revoke", + httpClient: httpClient, + } + } +} + // Delete removes a Linear integration hook for an account. func (s *LinearIntegrationService) Delete(ctx context.Context, accountID uint) error { hooks, err := s.findLinearHooks(ctx, accountID) diff --git a/internal/service/message_service_test.go b/internal/service/message_service_test.go index 463fca71..26a39e63 100644 --- a/internal/service/message_service_test.go +++ b/internal/service/message_service_test.go @@ -45,8 +45,13 @@ func (m *mockMessageLLMProvider) ChatCompletionStream(ctx context.Context, req l // mockRetryListener implements channel.EventListener for capturing dispatched events. type mockRetryListener struct { - received bool - lastData map[string]interface{} + received bool + lastEventType channel.EventType + lastAccountID uint + lastInboxID uint + lastUserID uint + lastConvID uint + lastData map[string]interface{} } func (l *mockRetryListener) Name() string { @@ -55,6 +60,11 @@ func (l *mockRetryListener) Name() string { func (l *mockRetryListener) OnEvent(ctx context.Context, event *channel.ChannelEvent) error { l.received = true + l.lastEventType = event.Type + l.lastAccountID = event.AccountID + l.lastInboxID = event.InboxID + l.lastUserID = event.UserID + l.lastConvID = event.ConversationID l.lastData = event.Data return nil } @@ -587,7 +597,7 @@ func TestMessageService_ConversationScopedMessageActions(t *testing.T) { // ========== Update 测试 ========== func TestMessageService_Update(t *testing.T) { - db, _, _, svc := setupMessageServiceWithDefaultLLM(t) + db, _, dispatcher, svc := setupMessageServiceWithDefaultLLM(t) ctx := context.Background() account := createTestAccount(t, db) @@ -600,17 +610,30 @@ func TestMessageService_Update(t *testing.T) { Content: "原始内容", MessageType: "outgoing", ContentType: "text", SenderType: "user", Status: "sent", } require.NoError(t, db.Create(msg).Error) + listener := &mockRetryListener{} + dispatcher.Register(listener) - req := UpdateMessageRequest{Status: "delivered"} + req := UpdateMessageRequest{Status: "failed", ExternalError: "provider rejected message"} updated, err := svc.Update(ctx, account.ID, msg.ID, req) assert.NoError(t, err) - assert.Equal(t, "delivered", updated.Status) + assert.Equal(t, "failed", updated.Status) assert.Equal(t, "原始内容", updated.Content) + assert.JSONEq(t, `{"external_error":"provider rejected message"}`, string(updated.ContentAttributes)) + assert.True(t, listener.received) + assert.Equal(t, channel.EventMessageUpdated, listener.lastEventType) + assert.Equal(t, account.ID, listener.lastAccountID) + assert.Equal(t, inbox.ID, listener.lastInboxID) + assert.Equal(t, conv.ID, listener.lastConvID) + eventMessage, ok := listener.lastData["message"].(*model.Message) + require.True(t, ok) + assert.Equal(t, msg.ID, eventMessage.ID) + assert.Equal(t, "failed", eventMessage.Status) + assert.JSONEq(t, `{"external_error":"provider rejected message"}`, string(eventMessage.ContentAttributes)) reqEmpty := UpdateMessageRequest{} updated2, err2 := svc.Update(ctx, account.ID, msg.ID, reqEmpty) assert.NoError(t, err2) - assert.Equal(t, "delivered", updated2.Status) + assert.Equal(t, "failed", updated2.Status) assert.Equal(t, "原始内容", updated2.Content) _, err = svc.Update(ctx, account.ID, msg.ID, UpdateMessageRequest{Status: "invalid"}) @@ -638,7 +661,7 @@ func TestMessageService_Update(t *testing.T) { // ========== Delete 测试 ========== func TestMessageService_Delete(t *testing.T) { - db, _, _, svc := setupMessageServiceWithDefaultLLM(t) + db, _, dispatcher, svc := setupMessageServiceWithDefaultLLM(t) ctx := context.Background() account := createTestAccount(t, db) @@ -651,16 +674,34 @@ func TestMessageService_Delete(t *testing.T) { Content: "待删除消息", MessageType: "incoming", ContentType: "text", SenderType: "contact", } require.NoError(t, db.Create(msg).Error) + attachment := &model.Attachment{MessageID: msg.ID, AccountID: account.ID, FileType: "file", FileName: "delete.txt"} + require.NoError(t, db.Create(attachment).Error) + listener := &mockRetryListener{} + dispatcher.Register(listener) // 正常路径:Chatwoot 删除会保留消息并标记 content_attributes.deleted deleted, err := svc.Delete(ctx, account.ID, msg.ID) assert.NoError(t, err) assert.Equal(t, "This message was deleted", deleted.Content) + assert.True(t, listener.received) + assert.Equal(t, channel.EventMessageDeleted, listener.lastEventType) + assert.Equal(t, account.ID, listener.lastAccountID) + assert.Equal(t, inbox.ID, listener.lastInboxID) + assert.Equal(t, conv.ID, listener.lastConvID) + eventMessage, ok := listener.lastData["message"].(*model.Message) + require.True(t, ok) + assert.Equal(t, msg.ID, eventMessage.ID) + assert.Equal(t, "This message was deleted", eventMessage.Content) + assert.JSONEq(t, `{"deleted":true}`, string(eventMessage.ContentAttributes)) stored, err := svc.GetByAccountAndID(ctx, account.ID, msg.ID) assert.NoError(t, err) assert.JSONEq(t, `{"deleted":true}`, string(stored.ContentAttributes)) + var attachmentCount int64 + require.NoError(t, db.Model(&model.Attachment{}).Where("message_id = ?", msg.ID).Count(&attachmentCount).Error) + assert.Equal(t, int64(0), attachmentCount) + // 错误路径:accountID不匹配 msg2 := &model.Message{ ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID, @@ -857,6 +898,10 @@ func TestMessageService_Retry(t *testing.T) { assert.Equal(t, "sent", retried.Status) assert.JSONEq(t, `{}`, string(retried.ContentAttributes)) assert.True(t, listener.received) + assert.Equal(t, channel.EventMessageStatusUpdated, listener.lastEventType) + assert.Equal(t, account.ID, listener.lastAccountID) + assert.Equal(t, inbox.ID, listener.lastInboxID) + assert.Equal(t, conv.ID, listener.lastConvID) assert.Equal(t, "sent", listener.lastData["status"]) assert.Equal(t, msg.ID, listener.lastData["message_id"]) diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index d7617b5c..20834be5 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -136,6 +136,10 @@ func (s *NotificationService) GetUnreadCountByAccount(ctx context.Context, userI return s.notifRepo.CountUnreadByUserAndAccount(ctx, userID, accountID) } +func (s *NotificationService) CountNotificationsByAccount(ctx context.Context, userID, accountID uint) (int64, error) { + return s.notifRepo.CountByUserAndAccount(ctx, userID, accountID) +} + // --- Notification Preference operations --- // GetPreferences retrieves all notification preferences for a user within an account. diff --git a/internal/service/profile_service.go b/internal/service/profile_service.go index e07e36f9..ceba9a6c 100644 --- a/internal/service/profile_service.go +++ b/internal/service/profile_service.go @@ -67,7 +67,7 @@ type ProfileUserResponse struct { Name string `json:"name"` Provider string `json:"provider"` PubsubToken string `json:"pubsub_token"` - CustomAttributes map[string]any `json:"custom_attributes,omitempty"` + CustomAttributes map[string]any `json:"custom_attributes"` Role string `json:"role"` UISettings map[string]any `json:"ui_settings"` UID string `json:"uid"` diff --git a/internal/service/search_indexer_hooks_test.go b/internal/service/search_indexer_hooks_test.go index c8aad51e..61c9dfa7 100644 --- a/internal/service/search_indexer_hooks_test.go +++ b/internal/service/search_indexer_hooks_test.go @@ -17,6 +17,9 @@ type mockServiceSearchIndexer struct { indexedContactLabels map[uint][]string indexedConversationIDs []uint indexedConversationNames []string + indexedCompanyNames []string + indexedArticleTitles []string + indexedArticleStatuses []string } func (m *mockServiceSearchIndexer) IndexConversation(ctx context.Context, conversation *model.Conversation) error { @@ -63,6 +66,9 @@ func (m *mockServiceSearchIndexer) DeleteContact(ctx context.Context, accountID func (m *mockServiceSearchIndexer) IndexCompany(ctx context.Context, company *model.Company) error { m.indexed = append(m.indexed, "company") + if company != nil { + m.indexedCompanyNames = append(m.indexedCompanyNames, company.Name) + } return nil } @@ -73,6 +79,10 @@ func (m *mockServiceSearchIndexer) DeleteCompany(ctx context.Context, accountID func (m *mockServiceSearchIndexer) IndexArticle(ctx context.Context, article *model.Article) error { m.indexed = append(m.indexed, "article") + if article != nil { + m.indexedArticleTitles = append(m.indexedArticleTitles, article.Title) + m.indexedArticleStatuses = append(m.indexedArticleStatuses, article.Status) + } return nil } @@ -194,6 +204,7 @@ func TestCompanyService_SearchIndexHooks(t *testing.T) { require.NoError(t, svc.Delete(context.Background(), company.ID, account.ID)) assert.Equal(t, []string{"company", "company"}, indexer.indexed) + assert.Equal(t, []string{"Acme", "Acme Inc"}, indexer.indexedCompanyNames) assert.Equal(t, []string{"company"}, indexer.deleted) } @@ -205,10 +216,13 @@ func TestArticleService_SearchIndexHooks(t *testing.T) { article, err := svc.CreateWithAccount(context.Background(), 7, 3, 1, &CreateArticleRequest{Title: "Install", Slug: "install"}) require.NoError(t, err) newTitle := "Install GoChat" - _, err = svc.Update(context.Background(), article.ID, &UpdateArticleRequest{Title: &newTitle}) + published := model.ArticleStatusPublished + _, err = svc.Update(context.Background(), article.ID, &UpdateArticleRequest{Title: &newTitle, Status: &published}) require.NoError(t, err) require.NoError(t, svc.Delete(context.Background(), article.ID)) assert.Equal(t, []string{"article", "article"}, indexer.indexed) + assert.Equal(t, []string{"Install", "Install GoChat"}, indexer.indexedArticleTitles) + assert.Equal(t, []string{"draft", "published"}, indexer.indexedArticleStatuses) assert.Equal(t, []string{"article"}, indexer.deleted) } diff --git a/internal/service/slack_integration_service.go b/internal/service/slack_integration_service.go index 644548a3..db3dbab7 100644 --- a/internal/service/slack_integration_service.go +++ b/internal/service/slack_integration_service.go @@ -28,9 +28,24 @@ type SlackIntegrationService struct { client *slackAPIClient } +type SlackIntegrationOption func(*SlackIntegrationService) + // NewSlackIntegrationService creates a new SlackIntegrationService. -func NewSlackIntegrationService(hookRepo *repository.IntegrationHookRepo) *SlackIntegrationService { - return &SlackIntegrationService{hookRepo: hookRepo, client: newSlackAPIClientFromEnv()} +func NewSlackIntegrationService(hookRepo *repository.IntegrationHookRepo, options ...SlackIntegrationOption) *SlackIntegrationService { + svc := &SlackIntegrationService{hookRepo: hookRepo, client: newSlackAPIClientFromEnv()} + for _, option := range options { + option(svc) + } + return svc +} + +func WithSlackHTTPClient(baseURL string, httpClient *http.Client) SlackIntegrationOption { + return func(svc *SlackIntegrationService) { + if httpClient == nil { + return + } + svc.client = &slackAPIClient{baseURL: strings.TrimRight(baseURL, "/"), httpClient: httpClient} + } } // CreateSlackRequest is the DTO for creating/updating a Slack integration. diff --git a/internal/service/widget_service.go b/internal/service/widget_service.go index bcf6d3c5..3960e1b0 100644 --- a/internal/service/widget_service.go +++ b/internal/service/widget_service.go @@ -113,6 +113,7 @@ func (s *WidgetService) SetTranscriptDeliverer(deliverer automation.AutomationTr // contact attributes are optional (anonymous visitor if not provided). type WidgetInitRequest struct { WebsiteToken string `json:"website_token" validate:"required"` + WidgetToken string `json:"widget_token,omitempty"` ContactName string `json:"contact_name,omitempty"` ContactEmail string `json:"contact_email,omitempty"` ContactPhone string `json:"contact_phone,omitempty"` @@ -284,16 +285,29 @@ func (s *WidgetService) Init(ctx context.Context, req WidgetInitRequest) (*Widge return nil, fmt.Errorf("invalid widget config: %w", err) } - // Step 2: Find or create contact - contact, err := s.findOrCreateWidgetContact(ctx, inbox.AccountID, req) - if err != nil { - return nil, fmt.Errorf("failed to identify contact: %w", err) + var contactInbox *model.ContactInbox + var contact *model.Contact + if req.WidgetToken != "" { + contactInbox, err = s.contactInboxRepo.FindByPubsubToken(ctx, req.WidgetToken) + if err == nil && contactInbox.InboxID == inbox.ID { + contact = &contactInbox.Contact + } else { + contactInbox = nil + } + } + if contact == nil { + contact, err = s.findOrCreateWidgetContact(ctx, inbox.AccountID, req) + if err != nil { + return nil, fmt.Errorf("failed to identify contact: %w", err) + } } // Step 3: Find or create ContactInbox - contactInbox, err := s.findOrCreateContactInbox(ctx, contact.ID, inbox.ID) - if err != nil { - return nil, fmt.Errorf("failed to create contact inbox: %w", err) + if contactInbox == nil { + contactInbox, err = s.findOrCreateContactInbox(ctx, contact.ID, inbox.ID) + if err != nil { + return nil, fmt.Errorf("failed to create contact inbox: %w", err) + } } if req.HMACVerified && !contactInbox.HMACVerified { contactInbox.HMACVerified = true diff --git a/internal/ws/event_publisher.go b/internal/ws/event_publisher.go index 61f59a31..975d2cd6 100644 --- a/internal/ws/event_publisher.go +++ b/internal/ws/event_publisher.go @@ -18,19 +18,20 @@ import ( // after create/update/delete actions via Wisper → ActionCable. // // Architecture: -// Service.CreateX() → EventPublisher.PublishEvent(accountID, eventType, payload) -// → Hub.SendToAccount (WebSocket local delivery) -// → SSERegistry.SendToAccount (SSE local delivery) -// → BroadcastRelay.Publish (Redis Pub/Sub cross-instance delivery) +// +// Service.CreateX() → EventPublisher.PublishEvent(accountID, eventType, payload) +// → Hub.SendToAccount (WebSocket local delivery) +// → SSERegistry.SendToAccount (SSE local delivery) +// → BroadcastRelay.Publish (Redis Pub/Sub cross-instance delivery) // // Reference: Chatwoot uses Wisper (in-process pub/sub) + ActionCable (WebSocket) // + Redis Pub/Sub for cross-instance. GoChat uses EventPublisher as the unified // entry point, with Hub and SSERegistry as local delivery targets, and Redis // Pub/Sub relay for multi-instance fan-out. type EventPublisher struct { - hub MessageHandler // WebSocket Hub (local delivery) - sse *SSERegistry // SSE registry (local delivery) - relay *BroadcastRelay // Redis Pub/Sub relay (cross-instance delivery) + hub MessageHandler // WebSocket Hub (local delivery) + sse *SSERegistry // SSE registry (local delivery) + relay *BroadcastRelay // Redis Pub/Sub relay (cross-instance delivery) } // NewEventPublisher creates an EventPublisher with all delivery targets. @@ -60,9 +61,9 @@ func NewEventPublisherLocal(hub MessageHandler, sse *SSERegistry) *EventPublishe // - payload: event data (will be JSON-encoded for transport) // // The event is routed to: -// 1. WebSocket Hub → all locally connected WS clients for that account -// 2. SSE Registry → all locally connected SSE clients for that account -// 3. Redis Pub/Sub relay → all other GoChat instances (cross-instance delivery) +// 1. WebSocket Hub → all locally connected WS clients for that account +// 2. SSE Registry → all locally connected SSE clients for that account +// 3. Redis Pub/Sub relay → all other GoChat instances (cross-instance delivery) // // Reference: Chatwoot controllers call broadcast_event after mutations, // which triggers Wisper → ActionCable → Redis Pub/Sub relay. @@ -145,6 +146,47 @@ func (p *EventPublisher) PublishConversationEvent(accountID uint, conversationID } } +// PublishWidgetEvent publishes an event to the account room and the widget +// contact's pubsub_token room. Chatwoot's widget ActionCable connector +// subscribes to RoomChannel with pubsub_token, so widget-visible events must be +// available on that token-scoped room in addition to the dashboard account room. +func (p *EventPublisher) PublishWidgetEvent(accountID uint, pubsubToken string, eventType string, payload interface{}) { + wsMsg := &WSMessage{ + Event: eventType, + Data: payload, + AccountID: accountID, + } + + data, err := json.Marshal(wsMsg) + if err != nil { + applogger.L().Warnf("event publisher: failed to marshal widget event %s: %v", eventType, err) + return + } + + if p.hub != nil { + p.hub.SendToAccount(accountID, data) + if pubsubToken != "" { + p.hub.SendToRoom(pubsubTokenRoomNameHelper(pubsubToken), data) + } + } + + if p.sse != nil { + p.sse.SendToAccount(accountID, SSEEvent{Type: eventType, Payload: payload}) + } + + if p.relay != nil { + room := accountRoomNameHelper(accountID) + if err := p.relay.Publish(context.Background(), room, wsMsg); err != nil { + applogger.L().Warnf("event publisher: redis publish failed for %s: %v", eventType, err) + } + if pubsubToken != "" { + if err := p.relay.Publish(context.Background(), pubsubTokenRoomNameHelper(pubsubToken), wsMsg); err != nil { + applogger.L().Warnf("event publisher: redis publish failed for widget %s: %v", eventType, err) + } + } + } +} + // accountRoomNameHelper generates the room name for an account channel. func accountRoomNameHelper(accountID uint) string { return fmt.Sprintf("account_%d", accountID) @@ -153,4 +195,8 @@ func accountRoomNameHelper(accountID uint) string { // conversationRoomNameHelper generates the room name for a conversation channel. func conversationRoomNameHelper(accountID uint, conversationID uint) string { return fmt.Sprintf("account_%d_conversation_%d", accountID, conversationID) -} \ No newline at end of file +} + +func pubsubTokenRoomNameHelper(token string) string { + return fmt.Sprintf("pubsub_token_%s", token) +} diff --git a/internal/ws/event_publisher_test.go b/internal/ws/event_publisher_test.go index 96259cac..43ffc62f 100644 --- a/internal/ws/event_publisher_test.go +++ b/internal/ws/event_publisher_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "sync" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -76,7 +77,7 @@ func TestEventPublisher_ConversationEvent(t *testing.T) { publisher := NewEventPublisherLocal(hub, sse) // Subscribe SSE clients - chBroad := sse.Subscribe("sse_broad", 1, 100) // no conv filter + chBroad := sse.Subscribe("sse_broad", 1, 100) // no conv filter chFiltered := sse.Subscribe("sse_filtered", 1, 200) sse.SubscribeConversation("sse_filtered", 42) @@ -135,6 +136,8 @@ func TestEventPublisher_AllEventTypes(t *testing.T) { EventInboxDeleted, EventNotificationCreated, EventNotificationUpdated, + EventNotificationDeleted, + EventAccountCacheInvalidated, } for _, eventType := range eventTypes { @@ -221,6 +224,406 @@ func TestEventPublisher_WSMessageFormat(t *testing.T) { assert.Equal(t, "test message", dataMap["content"]) } +func TestEventPublisher_MessageCreatedChatwootPayloadShape(t *testing.T) { + hub := newMockHandler() + sse := NewSSERegistry() + publisher := NewEventPublisherLocal(hub, sse) + + ch := sse.Subscribe("message-created-fixture", 1, 100) + require.NotNil(t, ch) + + payload := map[string]interface{}{ + "id": 501, + "conversation_id": 42, + "display_id": 42, + "account_id": 1, + "inbox_id": 7, + "message_type": "outgoing", + "content_type": "text", + "content": "hello from agent", + "content_attributes": map[string]interface{}{"submitted_email": "contact@example.com"}, + "sender": map[string]interface{}{ + "id": 100, + "name": "Agent Ada", + "type": "user", + "email": "ada@example.com", + }, + "attachments": []interface{}{ + map[string]interface{}{ + "id": 900, + "message_id": 501, + "file_type": "image", + "data_url": "https://cdn.example.test/image.png", + "download_url": "https://cdn.example.test/image.png", + "conversation_id": 42, + }, + }, + } + publisher.PublishEvent(1, EventMessageCreated, payload) + + data := hub.getAccountData(1) + require.NotNil(t, data) + var wsMsg WSMessage + require.NoError(t, json.Unmarshal(data, &wsMsg)) + require.Equal(t, EventMessageCreated, wsMsg.Event) + require.Equal(t, uint(1), wsMsg.AccountID) + + dataMap, ok := wsMsg.Data.(map[string]interface{}) + require.True(t, ok) + require.Equal(t, float64(501), dataMap["id"]) + require.Equal(t, float64(42), dataMap["conversation_id"]) + require.Equal(t, "outgoing", dataMap["message_type"]) + require.Equal(t, "text", dataMap["content_type"]) + require.Equal(t, "hello from agent", dataMap["content"]) + require.Contains(t, dataMap, "content_attributes") + require.Contains(t, dataMap, "sender") + require.Contains(t, dataMap, "attachments") + require.Equal(t, "Agent Ada", dataMap["sender"].(map[string]interface{})["name"]) + attachments := dataMap["attachments"].([]interface{}) + require.Len(t, attachments, 1) + require.Equal(t, float64(42), attachments[0].(map[string]interface{})["conversation_id"]) + + select { + case event := <-ch.Events: + require.Equal(t, EventMessageCreated, event.Type) + fixture := event.Payload.(map[string]interface{}) + require.Equal(t, "hello from agent", fixture["content"]) + require.Contains(t, fixture, "sender") + require.Contains(t, fixture, "attachments") + default: + t.Fatal("SSE channel should receive Chatwoot message.created payload") + } +} + +func TestEventPublisher_MessageUpdatedChatwootPayloadShape(t *testing.T) { + hub := newMockHandler() + sse := NewSSERegistry() + publisher := NewEventPublisherLocal(hub, sse) + + ch := sse.Subscribe("message-updated-fixture", 1, 100) + require.NotNil(t, ch) + + payload := map[string]interface{}{ + "id": 502, + "conversation_id": 42, + "account_id": 1, + "inbox_id": 7, + "message_type": "outgoing", + "content_type": "text", + "content": "provider rejected", + "status": "failed", + "content_attributes": map[string]interface{}{ + "external_error": "provider rejected message", + }, + "sender": map[string]interface{}{ + "id": 100, + "name": "Agent Ada", + "type": "user", + "email": "ada@example.com", + }, + "attachments": []interface{}{}, + } + publisher.PublishEvent(1, EventMessageUpdated, payload) + + data := hub.getAccountData(1) + require.NotNil(t, data) + var wsMsg WSMessage + require.NoError(t, json.Unmarshal(data, &wsMsg)) + require.Equal(t, EventMessageUpdated, wsMsg.Event) + require.Equal(t, uint(1), wsMsg.AccountID) + + dataMap, ok := wsMsg.Data.(map[string]interface{}) + require.True(t, ok) + require.Equal(t, float64(502), dataMap["id"]) + require.Equal(t, float64(42), dataMap["conversation_id"]) + require.Equal(t, "failed", dataMap["status"]) + require.Equal(t, "provider rejected", dataMap["content"]) + contentAttrs := dataMap["content_attributes"].(map[string]interface{}) + require.Equal(t, "provider rejected message", contentAttrs["external_error"]) + require.Contains(t, dataMap, "sender") + require.Contains(t, dataMap, "attachments") + + select { + case event := <-ch.Events: + require.Equal(t, EventMessageUpdated, event.Type) + fixture := event.Payload.(map[string]interface{}) + require.Equal(t, 502, fixture["id"]) + require.Equal(t, "failed", fixture["status"]) + case <-time.After(time.Second): + t.Fatal("SSE channel should receive message.updated fixture") + } +} + +func TestEventPublisher_ConversationMutationChatwootPayloadShape(t *testing.T) { + hub := newMockHandler() + sse := NewSSERegistry() + publisher := NewEventPublisherLocal(hub, sse) + + ch := sse.Subscribe("conversation-mutation-fixture", 1, 100) + require.NotNil(t, ch) + + payload := map[string]interface{}{ + "id": 42, + "account_id": 1, + "inbox_id": 7, + "status": "resolved", + "priority": "urgent", + "labels": []interface{}{"support", "urgent"}, + "assignee_id": 100, + "team_id": 55, + "changed_attributes": map[string]interface{}{ + "status": map[string]interface{}{"from": "open", "to": "resolved"}, + "priority": map[string]interface{}{"from": "medium", "to": "urgent"}, + }, + "meta": map[string]interface{}{ + "assignee": map[string]interface{}{"id": 100, "name": "Agent Ada"}, + "team": map[string]interface{}{"id": 55, "name": "Support"}, + }, + } + publisher.PublishEvent(1, EventConversationUpdated, payload) + + data := hub.getAccountData(1) + require.NotNil(t, data) + var wsMsg WSMessage + require.NoError(t, json.Unmarshal(data, &wsMsg)) + require.Equal(t, EventConversationUpdated, wsMsg.Event) + require.Equal(t, uint(1), wsMsg.AccountID) + + dataMap, ok := wsMsg.Data.(map[string]interface{}) + require.True(t, ok) + require.Equal(t, float64(42), dataMap["id"]) + require.Equal(t, "resolved", dataMap["status"]) + require.Equal(t, "urgent", dataMap["priority"]) + require.Contains(t, dataMap, "labels") + require.Contains(t, dataMap, "meta") + changes := dataMap["changed_attributes"].(map[string]interface{}) + require.Equal(t, "resolved", changes["status"].(map[string]interface{})["to"]) + require.Equal(t, "urgent", changes["priority"].(map[string]interface{})["to"]) + + select { + case event := <-ch.Events: + require.Equal(t, EventConversationUpdated, event.Type) + fixture := event.Payload.(map[string]interface{}) + require.Equal(t, 42, fixture["id"]) + require.Equal(t, "resolved", fixture["status"]) + case <-time.After(time.Second): + t.Fatal("SSE channel should receive conversation.updated fixture") + } +} + +func TestEventPublisher_TypingChatwootPayloadShape(t *testing.T) { + hub := newMockHandler() + sse := NewSSERegistry() + publisher := NewEventPublisherLocal(hub, sse) + + ch := sse.Subscribe("typing-fixture", 1, 100) + sse.SubscribeConversation("typing-fixture", 42) + require.NotNil(t, ch) + + payload := map[string]interface{}{ + "conversation_id": 42, + "account_id": 1, + "user_id": 100, + "typing_status": "on", + "is_private": true, + } + publisher.PublishConversationEvent(1, 42, EventConversationTypingOn, payload) + + data := hub.getAccountData(1) + require.NotNil(t, data) + var wsMsg WSMessage + require.NoError(t, json.Unmarshal(data, &wsMsg)) + require.Equal(t, EventConversationTypingOn, wsMsg.Event) + dataMap := wsMsg.Data.(map[string]interface{}) + require.Equal(t, float64(42), dataMap["conversation_id"]) + require.Equal(t, float64(100), dataMap["user_id"]) + require.Equal(t, "on", dataMap["typing_status"]) + require.Equal(t, true, dataMap["is_private"]) + + select { + case event := <-ch.Events: + require.Equal(t, EventConversationTypingOn, event.Type) + fixture := event.Payload.(map[string]interface{}) + require.Equal(t, 42, fixture["conversation_id"]) + require.Equal(t, "on", fixture["typing_status"]) + case <-time.After(time.Second): + t.Fatal("SSE channel should receive conversation.typing_on fixture") + } +} + +func TestEventPublisher_PresenceUpdateChatwootPayloadShape(t *testing.T) { + hub := newMockHandler() + sse := NewSSERegistry() + publisher := NewEventPublisherLocal(hub, sse) + + ch := sse.Subscribe("presence-fixture", 1, 100) + require.NotNil(t, ch) + + payload := map[string]interface{}{ + "account_id": 1, + "users": map[uint]string{100: "busy"}, + "contacts": map[uint]string{501: "online"}, + } + publisher.PublishEvent(1, EventPresenceUpdate, payload) + + data := hub.getAccountData(1) + require.NotNil(t, data) + var wsMsg WSMessage + require.NoError(t, json.Unmarshal(data, &wsMsg)) + require.Equal(t, EventPresenceUpdate, wsMsg.Event) + require.Equal(t, uint(1), wsMsg.AccountID) + + dataMap := wsMsg.Data.(map[string]interface{}) + require.Equal(t, float64(1), dataMap["account_id"]) + users := dataMap["users"].(map[string]interface{}) + require.Equal(t, "busy", users["100"]) + contacts := dataMap["contacts"].(map[string]interface{}) + require.Equal(t, "online", contacts["501"]) + + select { + case event := <-ch.Events: + require.Equal(t, EventPresenceUpdate, event.Type) + fixture := event.Payload.(map[string]interface{}) + require.Equal(t, map[uint]string{100: "busy"}, fixture["users"]) + case <-time.After(time.Second): + t.Fatal("SSE channel should receive presence.update fixture") + } +} + +func TestEventPublisher_NotificationChatwootPayloadShape(t *testing.T) { + hub := newMockHandler() + sse := NewSSERegistry() + publisher := NewEventPublisherLocal(hub, sse) + + ch := sse.Subscribe("notification-fixture", 1, 100) + require.NotNil(t, ch) + + notification := map[string]interface{}{ + "id": 701, + "account_id": 1, + "user_id": 100, + "notification_type": "conversation_created", + "primary_actor_type": "Conversation", + "primary_actor_id": 42, + "secondary_actor_type": "Message", + "secondary_actor_id": 501, + "read_at": nil, + "snoozed_until": nil, + "created_at": "2026-06-12T10:00:00Z", + "last_activity_at": "2026-06-12T10:01:00Z", + "meta": map[string]interface{}{ + "sender": map[string]interface{}{"id": 301, "name": "Visitor"}, + }, + "additional_attributes": map[string]interface{}{ + "browser_title": "New customer message", + }, + "primary_actor": map[string]interface{}{ + "id": 42, + "display_id": 42, + "status": "open", + }, + "secondary_actor": map[string]interface{}{ + "id": 501, + "conversation_id": 42, + "content": "hello", + }, + "user": map[string]interface{}{ + "id": 100, + "name": "Agent Ada", + }, + } + + for _, eventType := range []string{EventNotificationCreated, EventNotificationUpdated, EventNotificationDeleted} { + t.Run(eventType, func(t *testing.T) { + payload := map[string]interface{}{ + "notification": notification, + "unread_count": 3, + "count": 7, + } + publisher.PublishEvent(1, eventType, payload) + + data := hub.getAccountData(1) + require.NotNil(t, data) + var wsMsg WSMessage + require.NoError(t, json.Unmarshal(data, &wsMsg)) + require.Equal(t, eventType, wsMsg.Event) + require.Equal(t, uint(1), wsMsg.AccountID) + + dataMap, ok := wsMsg.Data.(map[string]interface{}) + require.True(t, ok) + require.Equal(t, float64(3), dataMap["unread_count"]) + require.Equal(t, float64(7), dataMap["count"]) + + notificationMap := dataMap["notification"].(map[string]interface{}) + require.Equal(t, float64(701), notificationMap["id"]) + require.Equal(t, float64(1), notificationMap["account_id"]) + require.Equal(t, "conversation_created", notificationMap["notification_type"]) + require.Equal(t, "Conversation", notificationMap["primary_actor_type"]) + require.Equal(t, float64(42), notificationMap["primary_actor_id"]) + require.Equal(t, "Message", notificationMap["secondary_actor_type"]) + require.Equal(t, float64(501), notificationMap["secondary_actor_id"]) + require.Contains(t, notificationMap, "read_at") + require.Contains(t, notificationMap, "snoozed_until") + require.Contains(t, notificationMap, "meta") + require.Contains(t, notificationMap, "additional_attributes") + require.Contains(t, notificationMap, "primary_actor") + require.Contains(t, notificationMap, "secondary_actor") + require.Contains(t, notificationMap, "user") + + select { + case event := <-ch.Events: + require.Equal(t, eventType, event.Type) + fixture := event.Payload.(map[string]interface{}) + require.Equal(t, 3, fixture["unread_count"]) + require.Equal(t, 7, fixture["count"]) + require.Equal(t, notification, fixture["notification"]) + case <-time.After(time.Second): + t.Fatalf("SSE channel should receive %s fixture", eventType) + } + }) + } +} + +func TestEventPublisher_AccountCacheInvalidatedChatwootPayloadShape(t *testing.T) { + hub := newMockHandler() + sse := NewSSERegistry() + publisher := NewEventPublisherLocal(hub, sse) + + ch := sse.Subscribe("account-cache-fixture", 1, 100) + require.NotNil(t, ch) + + payload := map[string]interface{}{ + "cache_keys": map[string]interface{}{ + "label": "labels-v2", + "inbox": "inboxes-v2", + "team": "teams-v2", + }, + } + publisher.PublishEvent(1, EventAccountCacheInvalidated, payload) + + data := hub.getAccountData(1) + require.NotNil(t, data) + var wsMsg WSMessage + require.NoError(t, json.Unmarshal(data, &wsMsg)) + require.Equal(t, EventAccountCacheInvalidated, wsMsg.Event) + require.Equal(t, uint(1), wsMsg.AccountID) + + dataMap := wsMsg.Data.(map[string]interface{}) + cacheKeys := dataMap["cache_keys"].(map[string]interface{}) + require.Equal(t, "labels-v2", cacheKeys["label"]) + require.Equal(t, "inboxes-v2", cacheKeys["inbox"]) + require.Equal(t, "teams-v2", cacheKeys["team"]) + + select { + case event := <-ch.Events: + require.Equal(t, EventAccountCacheInvalidated, event.Type) + fixture := event.Payload.(map[string]interface{}) + require.Equal(t, payload["cache_keys"], fixture["cache_keys"]) + case <-time.After(time.Second): + t.Fatal("SSE channel should receive account.cache_invalidated fixture") + } +} + // === Mock MessageHandler reuse === // The mockHandler type is defined in broadcast_test.go in the same package. // We reuse it here for EventPublisher tests. If needed, here's a duplicate @@ -231,7 +634,7 @@ func TestEventPublisher_WSMessageFormat(t *testing.T) { // Additional mock for tracking room-based sends type mockRoomHandler struct { - mu sync.Mutex + mu sync.Mutex accounts map[uint][]byte rooms map[string][]byte } @@ -283,4 +686,26 @@ func TestEventPublisher_ConversationEvent_RoomDelivery(t *testing.T) { convRoom := conversationRoomNameHelper(1, 42) roomData := hub.getRoomData(convRoom) require.NotNil(t, roomData, "Hub conversation room should receive event") -} \ No newline at end of file +} + +func TestEventPublisher_WidgetEvent_PubsubTokenRoomDelivery(t *testing.T) { + hub := newMockRoomHandler() + sse := NewSSERegistry() + publisher := NewEventPublisherLocal(hub, sse) + + payload := map[string]interface{}{"id": 7, "content": "hello widget"} + publisher.PublishWidgetEvent(1, "pubsub-123", EventMessageCreated, payload) + + accountData := hub.getAccountData(1) + require.NotNil(t, accountData, "Hub account room should receive widget event") + var accountMsg WSMessage + require.NoError(t, json.Unmarshal(accountData, &accountMsg)) + assert.Equal(t, EventMessageCreated, accountMsg.Event) + + roomData := hub.getRoomData("pubsub_token_pubsub-123") + require.NotNil(t, roomData, "Hub pubsub_token room should receive widget event") + var roomMsg WSMessage + require.NoError(t, json.Unmarshal(roomData, &roomMsg)) + assert.Equal(t, EventMessageCreated, roomMsg.Event) + assert.Equal(t, uint(1), roomMsg.AccountID) +} diff --git a/internal/ws/event_types.go b/internal/ws/event_types.go index 3199a01a..43688c75 100644 --- a/internal/ws/event_types.go +++ b/internal/ws/event_types.go @@ -41,6 +41,7 @@ const ( EventContactUpdated = "contact.updated" EventContactDeleted = "contact.deleted" EventContactMerged = "contact.merged" + EventCompanyUpdated = "company.updated" ) // Notification events @@ -78,23 +79,24 @@ const ( // --- Protocol control event constants --- const ( - EventSubscribeConfirm = "subscribe.confirm" - EventUnsubscribeConfirm = "unsubscribe.confirm" - EventSubscribeReject = "subscribe.reject" - EventPingResponse = "ping.response" - EventWelcome = "welcome" - EventDisconnect = "disconnect" + EventSubscribeConfirm = "subscribe.confirm" + EventUnsubscribeConfirm = "unsubscribe.confirm" + EventSubscribeReject = "subscribe.reject" + EventPingResponse = "ping.response" + EventWelcome = "welcome" + EventDisconnect = "disconnect" ) // --- Wire-format message types --- // WSMessage is the JSON envelope for all server→client WebSocket messages. // Mirrors Chatwoot's ActionCableBroadcastJob event format: -// {event: "message.created", data: {...}, account_id: 1, performer: {id, name, type, avatar_url}} +// +// {event: "message.created", data: {...}, account_id: 1, performer: {id, name, type, avatar_url}} type WSMessage struct { - Event string `json:"event"` // e.g. "message.created" - Data any `json:"data"` // event payload - AccountID uint `json:"account_id,omitempty"` // target account - Performer *Performer `json:"performer,omitempty"` // who triggered the event + Event string `json:"event"` // e.g. "message.created" + Data any `json:"data"` // event payload + AccountID uint `json:"account_id,omitempty"` // target account + Performer *Performer `json:"performer,omitempty"` // who triggered the event } // Performer describes who triggered an event, mirroring Chatwoot's @@ -102,24 +104,24 @@ type WSMessage struct { type Performer struct { ID uint `json:"id"` Name string `json:"name"` - Type string `json:"type"` // "user" or "contact" + Type string `json:"type"` // "user" or "contact" AvatarURL string `json:"avatar_url"` } // WSCommand is the JSON envelope for client→server WebSocket commands. // Mirrors Chatwoot's ActionCable command structure. type WSCommand struct { - Command string `json:"command"` // subscribe, unsubscribe, ping, typing_on, typing_off, update_presence - Data string `json:"data,omitempty"` // JSON-encoded command payload + Command string `json:"command"` // subscribe, unsubscribe, ping, typing_on, typing_off, update_presence + Data string `json:"data,omitempty"` // JSON-encoded command payload } // SubscribeData is the payload for a subscribe command. // Mirrors Chatwoot RoomChannel identifier. type SubscribeData struct { - Channel string `json:"channel"` // "AccountChannel" or "ConversationChannel" - AccountID uint `json:"account_id"` // required for both channels - PubsubToken string `json:"pubsub_token,omitempty"` // for contact-based auth - ConversationID uint `json:"conversation_id,omitempty"` // required for ConversationChannel + Channel string `json:"channel"` // "AccountChannel" or "ConversationChannel" + AccountID uint `json:"account_id"` // required for both channels + PubsubToken string `json:"pubsub_token,omitempty"` // for contact-based auth + ConversationID uint `json:"conversation_id,omitempty"` // required for ConversationChannel } // TypingData is the payload for typing_on/typing_off commands. @@ -142,13 +144,13 @@ const ( // --- Redis key prefix constants --- const ( // Pub/Sub channel prefixes for cross-instance relay - RedisPrefixRoom = "gochat:ws:room:" // gochat:ws:room:account_{id} - RedisPrefixAccount = "gochat:ws:account:" // gochat:ws:account:{id} + RedisPrefixRoom = "gochat:ws:room:" // gochat:ws:room:account_{id} + RedisPrefixAccount = "gochat:ws:account:" // gochat:ws:account:{id} // Sorted set / hash keys for presence tracking - RedisKeyPresenceAgents = "gochat:presence:agents" // sorted set: score=timestamp, member=agent_id:account_id - RedisKeyPresenceContacts = "gochat:presence:contacts" // sorted set: score=timestamp, member=contact_id:account_id - RedisKeyPresenceStatus = "gochat:presence:status" // hash: field=id:account_id, value=status string + RedisKeyPresenceAgents = "gochat:presence:agents" // sorted set: score=timestamp, member=agent_id:account_id + RedisKeyPresenceContacts = "gochat:presence:contacts" // sorted set: score=timestamp, member=contact_id:account_id + RedisKeyPresenceStatus = "gochat:presence:status" // hash: field=id:account_id, value=status string // Typing indicator keys with TTL RedisKeyTyping = "gochat:typing:%d:%d" // gochat:typing:{account_id}:{conversation_id} @@ -166,4 +168,4 @@ const ( // TypingTTLSec is how long a typing indicator persists before auto-expiry. TypingTTLSec = 4 -) \ No newline at end of file +) diff --git a/internal/ws/presence.go b/internal/ws/presence.go index e5b99a9b..e43f6640 100644 --- a/internal/ws/presence.go +++ b/internal/ws/presence.go @@ -59,15 +59,7 @@ func (p *PresenceTracker) SetAgentOnline(ctx context.Context, agentID, accountID return fmt.Errorf("failed to set agent status: %w", err) } - // Broadcast presence update event - msg := &WSMessage{ - Event: EventAgentOnline, - Data: map[string]any{ - "agent_id": agentID, - "account_id": accountID, - }, - AccountID: accountID, - } + msg := presenceUpdateMessage(accountID, map[uint]string{agentID: "online"}, nil) if err := p.relay.PublishAccount(ctx, accountID, msg); err != nil { logger.L().Warnf("ws presence: failed to broadcast agent online: %v", err) @@ -94,15 +86,7 @@ func (p *PresenceTracker) SetAgentOffline(ctx context.Context, agentID, accountI return fmt.Errorf("failed to remove agent status: %w", err) } - // Broadcast offline event - msg := &WSMessage{ - Event: EventAgentOffline, - Data: map[string]any{ - "agent_id": agentID, - "account_id": accountID, - }, - AccountID: accountID, - } + msg := presenceUpdateMessage(accountID, map[uint]string{agentID: "offline"}, nil) if err := p.relay.PublishAccount(ctx, accountID, msg); err != nil { logger.L().Warnf("ws presence: failed to broadcast agent offline: %v", err) @@ -129,16 +113,7 @@ func (p *PresenceTracker) SetAgentBusy(ctx context.Context, agentID, accountID u return fmt.Errorf("failed to set agent status busy: %w", err) } - // Broadcast presence update - msg := &WSMessage{ - Event: EventPresenceUpdate, - Data: map[string]any{ - "agent_id": agentID, - "account_id": accountID, - "status": "busy", - }, - AccountID: accountID, - } + msg := presenceUpdateMessage(accountID, map[uint]string{agentID: "busy"}, nil) if err := p.relay.PublishAccount(ctx, accountID, msg); err != nil { logger.L().Warnf("ws presence: failed to broadcast agent busy: %v", err) @@ -164,6 +139,11 @@ func (p *PresenceTracker) SetContactOnline(ctx context.Context, contactID, accou return fmt.Errorf("failed to set contact status: %w", err) } + msg := presenceUpdateMessage(accountID, nil, map[uint]string{contactID: "online"}) + if err := p.relay.PublishAccount(ctx, accountID, msg); err != nil { + logger.L().Warnf("ws presence: failed to broadcast contact online: %v", err) + } + logger.L().Debugf("ws presence: contact %d online for account %d", contactID, accountID) return nil } @@ -180,10 +160,33 @@ func (p *PresenceTracker) SetContactOffline(ctx context.Context, contactID, acco return fmt.Errorf("failed to remove contact status: %w", err) } + msg := presenceUpdateMessage(accountID, nil, map[uint]string{contactID: "offline"}) + if err := p.relay.PublishAccount(ctx, accountID, msg); err != nil { + logger.L().Warnf("ws presence: failed to broadcast contact offline: %v", err) + } + logger.L().Debugf("ws presence: contact %d offline for account %d", contactID, accountID) return nil } +func presenceUpdateMessage(accountID uint, users map[uint]string, contacts map[uint]string) *WSMessage { + if users == nil { + users = map[uint]string{} + } + if contacts == nil { + contacts = map[uint]string{} + } + return &WSMessage{ + Event: EventPresenceUpdate, + Data: map[string]any{ + "account_id": accountID, + "users": users, + "contacts": contacts, + }, + AccountID: accountID, + } +} + // GetOnlineAgentsForAccount returns all currently online agent IDs for an account. // Only returns agents whose sorted-set score is within PresenceDurationAgentSec // of the current time (i.e., have sent a heartbeat recently). @@ -255,6 +258,24 @@ func (p *PresenceTracker) CleanupExpired(ctx context.Context) error { agentThreshold := float64(time.Now().Unix() - PresenceDurationAgentSec) contactThreshold := float64(time.Now().Unix() - PresenceDurationContactSec) + expiredAgents, agentListErr := p.rdb.ZRangeByScore(ctx, RedisKeyPresenceAgents, &redis.ZRangeBy{ + Min: "0", + Max: fmt.Sprintf("%f", agentThreshold), + }).Result() + if agentListErr != nil { + logger.L().Warnf("ws presence: failed to list expired agents: %v", agentListErr) + expiredAgents = nil + } + + expiredContacts, contactListErr := p.rdb.ZRangeByScore(ctx, RedisKeyPresenceContacts, &redis.ZRangeBy{ + Min: "0", + Max: fmt.Sprintf("%f", contactThreshold), + }).Result() + if contactListErr != nil { + logger.L().Warnf("ws presence: failed to list expired contacts: %v", contactListErr) + expiredContacts = nil + } + // Remove expired agents removedAgents, err := p.rdb.ZRemRangeByScore(ctx, RedisKeyPresenceAgents, "0", fmt.Sprintf("%f", agentThreshold)).Result() @@ -269,6 +290,8 @@ func (p *PresenceTracker) CleanupExpired(ctx context.Context) error { logger.L().Warnf("ws presence: failed to cleanup expired contacts: %v", err) } + p.cleanupExpiredStatusesAndBroadcast(ctx, expiredAgents, expiredContacts) + if removedAgents > 0 || removedContacts > 0 { logger.L().Infof("ws presence: cleanup removed %d agents, %d contacts", removedAgents, removedContacts) @@ -276,6 +299,61 @@ func (p *PresenceTracker) CleanupExpired(ctx context.Context) error { return nil } +func (p *PresenceTracker) cleanupExpiredStatusesAndBroadcast(ctx context.Context, expiredAgents, expiredContacts []string) { + updates := map[uint]struct { + users map[uint]string + contacts map[uint]string + }{} + + addUpdate := func(accountID uint) struct { + users map[uint]string + contacts map[uint]string + } { + update := updates[accountID] + if update.users == nil { + update.users = map[uint]string{} + } + if update.contacts == nil { + update.contacts = map[uint]string{} + } + updates[accountID] = update + return update + } + + for _, member := range expiredAgents { + agentID, accountID := parsePresenceMember(member) + if agentID == 0 || accountID == 0 { + continue + } + update := addUpdate(accountID) + update.users[agentID] = "offline" + updates[accountID] = update + if err := p.rdb.HDel(ctx, RedisKeyPresenceStatus, member).Err(); err != nil { + logger.L().Warnf("ws presence: failed to delete expired agent status %s: %v", member, err) + } + } + + for _, member := range expiredContacts { + contactID, accountID := parsePresenceMember(member) + if contactID == 0 || accountID == 0 { + continue + } + update := addUpdate(accountID) + update.contacts[contactID] = "offline" + updates[accountID] = update + if err := p.rdb.HDel(ctx, RedisKeyPresenceStatus, member).Err(); err != nil { + logger.L().Warnf("ws presence: failed to delete expired contact status %s: %v", member, err) + } + } + + for accountID, update := range updates { + msg := presenceUpdateMessage(accountID, update.users, update.contacts) + if err := p.relay.PublishAccount(ctx, accountID, msg); err != nil { + logger.L().Warnf("ws presence: failed to broadcast expired presence for account %d: %v", accountID, err) + } + } +} + // RefreshAgentPresence refreshes an agent's timestamp in the sorted set. // Called on each heartbeat to prevent the agent from being cleaned up. func (p *PresenceTracker) RefreshAgentPresence(ctx context.Context, agentID, accountID uint) error { diff --git a/internal/ws/presence_test.go b/internal/ws/presence_test.go index 9d29bf71..cac82ba0 100644 --- a/internal/ws/presence_test.go +++ b/internal/ws/presence_test.go @@ -2,6 +2,7 @@ package ws import ( "context" + "encoding/json" "fmt" "testing" "time" @@ -187,6 +188,47 @@ func TestSetAgentBusy(t *testing.T) { assert.Greater(t, score, 0.0, "busy agent 应仍在 sorted set 中") } +func TestPresenceTracker_BroadcastsChatwootPresenceUpdatePayload(t *testing.T) { + _, rdb, tracker, _, cleanup := setupPresenceTest(t) + defer cleanup() + + ctx := context.Background() + channel := RedisPrefixAccount + "5" + sub := rdb.Subscribe(ctx, channel) + defer sub.Close() + _, err := sub.Receive(ctx) + require.NoError(t, err) + + require.NoError(t, tracker.SetAgentBusy(ctx, 10, 5)) + msgCh := sub.Channel() + select { + case redisMsg := <-msgCh: + assert.Equal(t, channel, redisMsg.Channel) + var wsMsg WSMessage + require.NoError(t, json.Unmarshal([]byte(redisMsg.Payload), &wsMsg)) + assert.Equal(t, EventPresenceUpdate, wsMsg.Event) + data := wsMsg.Data.(map[string]interface{}) + assert.Equal(t, float64(5), data["account_id"]) + users := data["users"].(map[string]interface{}) + assert.Equal(t, "busy", users["10"]) + case <-time.After(2 * time.Second): + t.Fatal("should receive agent presence.update payload") + } + + require.NoError(t, tracker.SetContactOnline(ctx, 99, 5)) + select { + case redisMsg := <-msgCh: + var wsMsg WSMessage + require.NoError(t, json.Unmarshal([]byte(redisMsg.Payload), &wsMsg)) + assert.Equal(t, EventPresenceUpdate, wsMsg.Event) + data := wsMsg.Data.(map[string]interface{}) + contacts := data["contacts"].(map[string]interface{}) + assert.Equal(t, "online", contacts["99"]) + case <-time.After(2 * time.Second): + t.Fatal("should receive contact presence.update payload") + } +} + // --- SetContactOnline / SetContactOffline 测试 --- func TestSetContactOnline(t *testing.T) { @@ -466,6 +508,48 @@ func TestCleanupExpired(t *testing.T) { assert.Equal(t, int64(0), contactCount, "过期 contact 应被清理") } +func TestCleanupExpired_BroadcastsChatwootOfflinePayloadAndClearsStatus(t *testing.T) { + _, rdb, tracker, _, cleanup := setupPresenceTest(t) + defer cleanup() + + ctx := context.Background() + channel := RedisPrefixAccount + "10" + sub := rdb.Subscribe(ctx, channel) + defer sub.Close() + _, err := sub.Receive(ctx) + require.NoError(t, err) + + expiredAgentScore := float64(time.Now().Unix() - PresenceDurationAgentSec - 10) + expiredContactScore := float64(time.Now().Unix() - PresenceDurationContactSec - 10) + require.NoError(t, rdb.ZAdd(ctx, RedisKeyPresenceAgents, redis.Z{Score: expiredAgentScore, Member: "1:10"}).Err()) + require.NoError(t, rdb.HSet(ctx, RedisKeyPresenceStatus, "1:10", "busy").Err()) + require.NoError(t, rdb.ZAdd(ctx, RedisKeyPresenceContacts, redis.Z{Score: expiredContactScore, Member: "100:10"}).Err()) + require.NoError(t, rdb.HSet(ctx, RedisKeyPresenceStatus, "100:10", "online").Err()) + + require.NoError(t, tracker.CleanupExpired(ctx)) + + select { + case redisMsg := <-sub.Channel(): + require.Equal(t, channel, redisMsg.Channel) + var wsMsg WSMessage + require.NoError(t, json.Unmarshal([]byte(redisMsg.Payload), &wsMsg)) + require.Equal(t, EventPresenceUpdate, wsMsg.Event) + data := wsMsg.Data.(map[string]interface{}) + require.Equal(t, float64(10), data["account_id"]) + users := data["users"].(map[string]interface{}) + require.Equal(t, "offline", users["1"]) + contacts := data["contacts"].(map[string]interface{}) + require.Equal(t, "offline", contacts["100"]) + case <-time.After(2 * time.Second): + t.Fatal("should receive presence.update offline payload for expired records") + } + + _, err = rdb.HGet(ctx, RedisKeyPresenceStatus, "1:10").Result() + require.Equal(t, redis.Nil, err) + _, err = rdb.HGet(ctx, RedisKeyPresenceStatus, "100:10").Result() + require.Equal(t, redis.Nil, err) +} + func TestCleanupExpired_NoExpired(t *testing.T) { _, _, tracker, _, cleanup := setupPresenceTest(t) defer cleanup() diff --git a/migrations/000005_add_dashboard_apps_table.up.sql b/migrations/000005_add_dashboard_apps_table.up.sql index c1bc9ad6..2acd50ed 100644 --- a/migrations/000005_add_dashboard_apps_table.up.sql +++ b/migrations/000005_add_dashboard_apps_table.up.sql @@ -4,20 +4,32 @@ CREATE TABLE IF NOT EXISTS dashboard_apps ( id SERIAL PRIMARY KEY, - account_id INTEGER NOT NULL, - user_id INTEGER, -- optional: per-user dashboard - title VARCHAR(255) NOT NULL, -- dashboard display name - description TEXT DEFAULT '', -- dashboard description - icon VARCHAR(255) DEFAULT '', -- icon URL or icon name - url VARCHAR(512) DEFAULT '', -- primary iframe URL - kind VARCHAR(100) DEFAULT 'frame', -- app kind: frame, link - content JSONB DEFAULT '[]', -- iframe config array: [{"type":"frame","url":"..."}] - active BOOLEAN DEFAULT TRUE NOT NULL, -- whether the app is active + title VARCHAR(255) NOT NULL, + description TEXT DEFAULT '', + icon VARCHAR(255) DEFAULT '', + url VARCHAR(512) DEFAULT '', created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - deleted_at TIMESTAMP WITH TIME ZONE -- soft-delete support + deleted_at TIMESTAMP WITH TIME ZONE ); -CREATE INDEX idx_dashboard_apps_account_id ON dashboard_apps(account_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_dashboard_apps_user_id ON dashboard_apps(user_id) WHERE deleted_at IS NULL AND user_id IS NOT NULL; -CREATE INDEX idx_dashboard_apps_deleted_at ON dashboard_apps(deleted_at); +ALTER TABLE dashboard_apps ADD COLUMN IF NOT EXISTS account_id INTEGER; +ALTER TABLE dashboard_apps ADD COLUMN IF NOT EXISTS user_id INTEGER; +ALTER TABLE dashboard_apps ADD COLUMN IF NOT EXISTS kind VARCHAR(100) DEFAULT 'frame'; +ALTER TABLE dashboard_apps ADD COLUMN IF NOT EXISTS content JSONB DEFAULT '[]'; +ALTER TABLE dashboard_apps ADD COLUMN IF NOT EXISTS active BOOLEAN DEFAULT TRUE; + +UPDATE dashboard_apps SET account_id = 0 WHERE account_id IS NULL; +ALTER TABLE dashboard_apps ALTER COLUMN account_id SET NOT NULL; +ALTER TABLE dashboard_apps ALTER COLUMN description SET DEFAULT ''; +ALTER TABLE dashboard_apps ALTER COLUMN icon SET DEFAULT ''; +ALTER TABLE dashboard_apps ALTER COLUMN url TYPE VARCHAR(512); +ALTER TABLE dashboard_apps ALTER COLUMN url SET DEFAULT ''; +ALTER TABLE dashboard_apps ALTER COLUMN kind SET DEFAULT 'frame'; +ALTER TABLE dashboard_apps ALTER COLUMN content SET DEFAULT '[]'; +ALTER TABLE dashboard_apps ALTER COLUMN active SET DEFAULT TRUE; +ALTER TABLE dashboard_apps ALTER COLUMN active SET NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_dashboard_apps_account_id ON dashboard_apps(account_id) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_dashboard_apps_user_id ON dashboard_apps(user_id) WHERE deleted_at IS NULL AND user_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_dashboard_apps_deleted_at ON dashboard_apps(deleted_at); diff --git a/migrations/000006_add_contact_extended_fields.down.sql b/migrations/000006_add_contact_extended_fields.down.sql index b2f22400..991a8805 100644 --- a/migrations/000006_add_contact_extended_fields.down.sql +++ b/migrations/000006_add_contact_extended_fields.down.sql @@ -5,6 +5,7 @@ DROP INDEX IF EXISTS idx_contacts_blocked; ALTER TABLE contacts DROP COLUMN IF EXISTS company_id; ALTER TABLE contacts DROP COLUMN IF EXISTS source_id; +ALTER TABLE contact_inboxes DROP COLUMN IF EXISTS hmac_verified; ALTER TABLE contacts DROP COLUMN IF EXISTS location; ALTER TABLE contacts DROP COLUMN IF EXISTS country_code; ALTER TABLE contacts DROP COLUMN IF EXISTS last_name; @@ -12,4 +13,4 @@ ALTER TABLE contacts DROP COLUMN IF EXISTS middle_name; ALTER TABLE contacts DROP COLUMN IF EXISTS contact_type; ALTER TABLE contacts DROP COLUMN IF EXISTS blocked; ALTER TABLE contacts DROP COLUMN IF EXISTS custom_attributes; -ALTER TABLE contacts DROP COLUMN IF EXISTS additional_attributes; \ No newline at end of file +ALTER TABLE contacts DROP COLUMN IF EXISTS additional_attributes; diff --git a/migrations/000006_add_contact_extended_fields.up.sql b/migrations/000006_add_contact_extended_fields.up.sql index 8b7889c8..abd499c3 100644 --- a/migrations/000006_add_contact_extended_fields.up.sql +++ b/migrations/000006_add_contact_extended_fields.up.sql @@ -15,5 +15,7 @@ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS location VARCHAR(255) DEFAULT ''; ALTER TABLE contacts ADD COLUMN IF NOT EXISTS source_id VARCHAR(255); ALTER TABLE contacts ADD COLUMN IF NOT EXISTS company_id INTEGER; +ALTER TABLE contact_inboxes ADD COLUMN IF NOT EXISTS hmac_verified BOOLEAN DEFAULT FALSE; + CREATE INDEX IF NOT EXISTS idx_contacts_blocked ON contacts(blocked) WHERE deleted_at IS NULL; -CREATE INDEX IF NOT EXISTS idx_contacts_contact_type ON contacts(contact_type) WHERE deleted_at IS NULL AND contact_type != ''; \ No newline at end of file +CREATE INDEX IF NOT EXISTS idx_contacts_contact_type ON contacts(contact_type) WHERE deleted_at IS NULL AND contact_type != ''; diff --git a/migrations/000008_add_search_indexes.up.sql b/migrations/000008_add_search_indexes.up.sql index 4a3059ac..016973bc 100644 --- a/migrations/000008_add_search_indexes.up.sql +++ b/migrations/000008_add_search_indexes.up.sql @@ -16,8 +16,21 @@ CREATE INDEX IF NOT EXISTS idx_messages_account_content_type ON messages (accoun CREATE INDEX IF NOT EXISTS idx_messages_account_private ON messages (account_id, private); CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages (conversation_id); --- Contacts: text search on name, email, phone; filter by source -CREATE INDEX IF NOT EXISTS idx_contacts_account_source ON contacts (account_id, source); +-- Contacts: text search on name, email, phone; filter by source/source_id. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'contacts' AND column_name = 'source' + ) THEN + CREATE INDEX IF NOT EXISTS idx_contacts_account_source ON contacts (account_id, source); + ELSIF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'contacts' AND column_name = 'source_id' + ) THEN + CREATE INDEX IF NOT EXISTS idx_contacts_account_source_id ON contacts (account_id, source_id); + END IF; +END $$; CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts (account_id, name); CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts (account_id, email); -CREATE INDEX IF NOT EXISTS idx_contacts_phone ON contacts (account_id, phone_number); \ No newline at end of file +CREATE INDEX IF NOT EXISTS idx_contacts_phone ON contacts (account_id, phone_number); diff --git a/migrations/000017_add_profile_serializer_fields.down.sql b/migrations/000017_add_profile_serializer_fields.down.sql index dfec279c..cd63ec11 100644 --- a/migrations/000017_add_profile_serializer_fields.down.sql +++ b/migrations/000017_add_profile_serializer_fields.down.sql @@ -7,5 +7,6 @@ ALTER TABLE users DROP COLUMN IF EXISTS custom_attributes; ALTER TABLE users DROP COLUMN IF EXISTS ui_settings; ALTER TABLE users DROP COLUMN IF EXISTS pubsub_token; ALTER TABLE users DROP COLUMN IF EXISTS message_signature; +ALTER TABLE users DROP COLUMN IF EXISTS password; ALTER TABLE accounts DROP COLUMN IF EXISTS onboarding_step; diff --git a/migrations/000017_add_profile_serializer_fields.up.sql b/migrations/000017_add_profile_serializer_fields.up.sql index 31620630..d427630d 100644 --- a/migrations/000017_add_profile_serializer_fields.up.sql +++ b/migrations/000017_add_profile_serializer_fields.up.sql @@ -2,6 +2,8 @@ ALTER TABLE accounts ADD COLUMN IF NOT EXISTS onboarding_step VARCHAR(100); +ALTER TABLE users ADD COLUMN IF NOT EXISTS password VARCHAR(255); +ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL; ALTER TABLE users ADD COLUMN IF NOT EXISTS message_signature TEXT; ALTER TABLE users ADD COLUMN IF NOT EXISTS pubsub_token VARCHAR(255); ALTER TABLE users ADD COLUMN IF NOT EXISTS ui_settings JSONB NOT NULL DEFAULT '{}'; diff --git a/migrations/000018_add_conversation_message_parity_fields.down.sql b/migrations/000018_add_conversation_message_parity_fields.down.sql index be35a355..35776553 100644 --- a/migrations/000018_add_conversation_message_parity_fields.down.sql +++ b/migrations/000018_add_conversation_message_parity_fields.down.sql @@ -11,6 +11,7 @@ ALTER TABLE conversations DROP COLUMN IF EXISTS resumed_at; ALTER TABLE conversations DROP COLUMN IF EXISTS resolved_at; ALTER TABLE conversations DROP COLUMN IF EXISTS muted; ALTER TABLE conversations DROP COLUMN IF EXISTS first_reply_created_at; +ALTER TABLE conversations DROP COLUMN IF EXISTS last_non_sys_msg_at; ALTER TABLE conversations DROP COLUMN IF EXISTS last_activity_at; ALTER TABLE conversations DROP COLUMN IF EXISTS waiting_since; ALTER TABLE conversations DROP COLUMN IF EXISTS contact_last_seen_at; @@ -22,5 +23,6 @@ ALTER TABLE conversations DROP COLUMN IF EXISTS snoozed_until; ALTER TABLE conversations DROP COLUMN IF EXISTS sla_policy_id; ALTER TABLE conversations DROP COLUMN IF EXISTS campaign_id; ALTER TABLE conversations DROP COLUMN IF EXISTS team_id; +ALTER TABLE conversations DROP COLUMN IF EXISTS assignee_agent_bot_id; ALTER TABLE conversations DROP COLUMN IF EXISTS contact_inbox_id; ALTER TABLE conversations DROP COLUMN IF EXISTS display_id; diff --git a/migrations/000018_add_conversation_message_parity_fields.up.sql b/migrations/000018_add_conversation_message_parity_fields.up.sql index df7b1cda..5fc7bd69 100644 --- a/migrations/000018_add_conversation_message_parity_fields.up.sql +++ b/migrations/000018_add_conversation_message_parity_fields.up.sql @@ -1,5 +1,6 @@ ALTER TABLE conversations ADD COLUMN IF NOT EXISTS display_id INTEGER; ALTER TABLE conversations ADD COLUMN IF NOT EXISTS contact_inbox_id INTEGER; +ALTER TABLE conversations ADD COLUMN IF NOT EXISTS assignee_agent_bot_id INTEGER; ALTER TABLE conversations ADD COLUMN IF NOT EXISTS team_id INTEGER; ALTER TABLE conversations ADD COLUMN IF NOT EXISTS campaign_id INTEGER; ALTER TABLE conversations ADD COLUMN IF NOT EXISTS sla_policy_id INTEGER; @@ -12,6 +13,7 @@ ALTER TABLE conversations ADD COLUMN IF NOT EXISTS contact_last_seen_at BIGINT; ALTER TABLE conversations ADD COLUMN IF NOT EXISTS waiting_since BIGINT; ALTER TABLE conversations ADD COLUMN IF NOT EXISTS last_activity_at BIGINT; ALTER TABLE conversations ADD COLUMN IF NOT EXISTS first_reply_created_at BIGINT; +ALTER TABLE conversations ADD COLUMN IF NOT EXISTS last_non_sys_msg_at BIGINT; ALTER TABLE conversations ADD COLUMN IF NOT EXISTS muted BOOLEAN DEFAULT FALSE; ALTER TABLE conversations ADD COLUMN IF NOT EXISTS resolved_at TIMESTAMP WITH TIME ZONE; ALTER TABLE conversations ADD COLUMN IF NOT EXISTS resumed_at TIMESTAMP WITH TIME ZONE; diff --git a/migrations/000024_add_account_inbox_limit.down.sql b/migrations/000024_add_account_inbox_limit.down.sql index 7b9eddfa..457dc4f4 100644 --- a/migrations/000024_add_account_inbox_limit.down.sql +++ b/migrations/000024_add_account_inbox_limit.down.sql @@ -1,3 +1,24 @@ ALTER TABLE accounts DROP COLUMN IF EXISTS inbox_limit; +ALTER TABLE accounts + DROP COLUMN IF EXISTS agent_limit; + +DROP INDEX IF EXISTS idx_inboxes_portal_id; + +ALTER TABLE inboxes DROP COLUMN IF EXISTS secret; +ALTER TABLE inboxes DROP COLUMN IF EXISTS webhook_url; +ALTER TABLE inboxes DROP COLUMN IF EXISTS portal_id; +ALTER TABLE inboxes DROP COLUMN IF EXISTS avatar_url; +ALTER TABLE inboxes DROP COLUMN IF EXISTS csat_config; +ALTER TABLE inboxes DROP COLUMN IF EXISTS business_name; +ALTER TABLE inboxes DROP COLUMN IF EXISTS sender_name_type; +ALTER TABLE inboxes DROP COLUMN IF EXISTS lock_to_single_conversation; +ALTER TABLE inboxes DROP COLUMN IF EXISTS allow_messages_after_resolved; +ALTER TABLE inboxes DROP COLUMN IF EXISTS timezone; +ALTER TABLE inboxes DROP COLUMN IF EXISTS out_of_office_message; +ALTER TABLE inboxes DROP COLUMN IF EXISTS working_hours_enabled; +ALTER TABLE inboxes DROP COLUMN IF EXISTS csat_survey_enabled; +ALTER TABLE inboxes DROP COLUMN IF EXISTS enable_email_collect; +ALTER TABLE inboxes DROP COLUMN IF EXISTS greeting_message; +ALTER TABLE inboxes DROP COLUMN IF EXISTS greeting_enabled; diff --git a/migrations/000024_add_account_inbox_limit.up.sql b/migrations/000024_add_account_inbox_limit.up.sql index 45035bcd..f70e9168 100644 --- a/migrations/000024_add_account_inbox_limit.up.sql +++ b/migrations/000024_add_account_inbox_limit.up.sql @@ -1,3 +1,24 @@ ALTER TABLE accounts ADD COLUMN IF NOT EXISTS inbox_limit INTEGER DEFAULT 0; +ALTER TABLE accounts + ADD COLUMN IF NOT EXISTS agent_limit INTEGER DEFAULT 0; + +ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS greeting_enabled BOOLEAN DEFAULT FALSE; +ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS greeting_message TEXT; +ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS enable_email_collect BOOLEAN DEFAULT TRUE; +ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS csat_survey_enabled BOOLEAN DEFAULT FALSE; +ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS working_hours_enabled BOOLEAN DEFAULT FALSE; +ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS out_of_office_message TEXT; +ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS timezone VARCHAR(100); +ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS allow_messages_after_resolved BOOLEAN DEFAULT TRUE; +ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS lock_to_single_conversation BOOLEAN DEFAULT FALSE; +ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS sender_name_type VARCHAR(50) DEFAULT 'friendly'; +ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS business_name VARCHAR(255); +ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS csat_config TEXT; +ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS avatar_url VARCHAR(1024); +ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS portal_id INTEGER; +ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS webhook_url VARCHAR(1024); +ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS secret VARCHAR(255); + +CREATE INDEX IF NOT EXISTS idx_inboxes_portal_id ON inboxes(portal_id) WHERE deleted_at IS NULL; diff --git a/migrations/000025_add_account_captain_preferences.down.sql b/migrations/000025_add_account_captain_preferences.down.sql index dff0f78e..2cbc55c0 100644 --- a/migrations/000025_add_account_captain_preferences.down.sql +++ b/migrations/000025_add_account_captain_preferences.down.sql @@ -1,3 +1,4 @@ ALTER TABLE accounts + DROP COLUMN IF EXISTS keep_pending_on_bot_failure, DROP COLUMN IF EXISTS captain_features, DROP COLUMN IF EXISTS captain_models; diff --git a/migrations/000025_add_account_captain_preferences.up.sql b/migrations/000025_add_account_captain_preferences.up.sql index a98645f9..82286d9f 100644 --- a/migrations/000025_add_account_captain_preferences.up.sql +++ b/migrations/000025_add_account_captain_preferences.up.sql @@ -2,4 +2,5 @@ ALTER TABLE accounts ADD COLUMN IF NOT EXISTS captain_models JSONB NOT NULL DEFAULT '{}', - ADD COLUMN IF NOT EXISTS captain_features JSONB NOT NULL DEFAULT '{}'; + ADD COLUMN IF NOT EXISTS captain_features JSONB NOT NULL DEFAULT '{}', + ADD COLUMN IF NOT EXISTS keep_pending_on_bot_failure BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/000034_align_calls_whatsapp_fields.up.sql b/migrations/000034_align_calls_whatsapp_fields.up.sql index 73e69dab..c61a99a5 100644 --- a/migrations/000034_align_calls_whatsapp_fields.up.sql +++ b/migrations/000034_align_calls_whatsapp_fields.up.sql @@ -1,2 +1,35 @@ +CREATE TABLE IF NOT EXISTS calls ( + id SERIAL PRIMARY KEY, + account_id INTEGER NOT NULL, + inbox_id INTEGER, + conversation_id INTEGER NOT NULL, + contact_id INTEGER, + message_id INTEGER, + accepted_by_agent_id INTEGER, + provider VARCHAR(50) DEFAULT 'twilio', + direction VARCHAR(50), + provider_call_id VARCHAR(255), + conference_sid VARCHAR(255), + caller_type VARCHAR(100) NOT NULL DEFAULT '', + caller_id INTEGER NOT NULL DEFAULT 0, + status VARCHAR(50) NOT NULL DEFAULT 'ringing', + duration INTEGER DEFAULT 0, + call_direction VARCHAR(50) NOT NULL DEFAULT '', + recording_url VARCHAR(512), + additional_attributes JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_calls_deleted_at ON calls(deleted_at); +CREATE INDEX IF NOT EXISTS idx_calls_account_id ON calls(account_id); +CREATE INDEX IF NOT EXISTS idx_calls_inbox_id ON calls(inbox_id); +CREATE INDEX IF NOT EXISTS idx_calls_conversation_id ON calls(conversation_id); +CREATE INDEX IF NOT EXISTS idx_calls_contact_id ON calls(contact_id); +CREATE INDEX IF NOT EXISTS idx_calls_message_id ON calls(message_id); +CREATE INDEX IF NOT EXISTS idx_calls_accepted_by_agent_id ON calls(accepted_by_agent_id); +CREATE INDEX IF NOT EXISTS idx_calls_provider_call_id ON calls(provider, provider_call_id); + ALTER TABLE calls ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ; ALTER TABLE calls ADD COLUMN IF NOT EXISTS end_reason VARCHAR(255); diff --git a/migrations/000038_add_channel_twitter_tweets_enabled.up.sql b/migrations/000038_add_channel_twitter_tweets_enabled.up.sql index 659bfe1d..fa64a74d 100644 --- a/migrations/000038_add_channel_twitter_tweets_enabled.up.sql +++ b/migrations/000038_add_channel_twitter_tweets_enabled.up.sql @@ -1,2 +1,7 @@ ALTER TABLE channel_twitters ADD COLUMN IF NOT EXISTS tweets_enabled BOOLEAN DEFAULT TRUE; -ALTER TABLE channel_twitter_profiles ADD COLUMN IF NOT EXISTS tweets_enabled BOOLEAN DEFAULT TRUE; +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'channel_twitter_profiles') THEN + ALTER TABLE channel_twitter_profiles ADD COLUMN IF NOT EXISTS tweets_enabled BOOLEAN DEFAULT TRUE; + END IF; +END $$; diff --git a/migrations/000039_add_companies_tables.down.sql b/migrations/000039_add_companies_tables.down.sql new file mode 100644 index 00000000..eb1ce4df --- /dev/null +++ b/migrations/000039_add_companies_tables.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS company_contacts; +DROP TABLE IF EXISTS companies; diff --git a/migrations/000039_add_companies_tables.up.sql b/migrations/000039_add_companies_tables.up.sql new file mode 100644 index 00000000..e2ab4f82 --- /dev/null +++ b/migrations/000039_add_companies_tables.up.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS companies ( + id SERIAL PRIMARY KEY, + account_id INTEGER NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT DEFAULT '', + website_url VARCHAR(512), + favicon_url VARCHAR(512), + domain VARCHAR(255), + last_activity_at TIMESTAMPTZ, + additional_attributes JSONB DEFAULT '{}', + custom_attributes JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_companies_account_id ON companies(account_id); +CREATE INDEX IF NOT EXISTS idx_companies_name ON companies(account_id, name); +CREATE INDEX IF NOT EXISTS idx_companies_domain ON companies(account_id, domain); + +CREATE TABLE IF NOT EXISTS company_contacts ( + company_id INTEGER NOT NULL, + contact_id INTEGER NOT NULL, + PRIMARY KEY (company_id, contact_id) +); + +CREATE INDEX IF NOT EXISTS idx_company_contacts_company_id ON company_contacts(company_id); +CREATE INDEX IF NOT EXISTS idx_company_contacts_contact_id ON company_contacts(contact_id); diff --git a/migrations/000040_add_sla_tables.down.sql b/migrations/000040_add_sla_tables.down.sql new file mode 100644 index 00000000..125325b0 --- /dev/null +++ b/migrations/000040_add_sla_tables.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS sla_events; +DROP TABLE IF EXISTS applied_slas; +DROP TABLE IF EXISTS sla_policy_inboxes; +DROP TABLE IF EXISTS sla_policies; diff --git a/migrations/000040_add_sla_tables.up.sql b/migrations/000040_add_sla_tables.up.sql new file mode 100644 index 00000000..36f5bdc9 --- /dev/null +++ b/migrations/000040_add_sla_tables.up.sql @@ -0,0 +1,75 @@ +CREATE TABLE IF NOT EXISTS sla_policies ( + id SERIAL PRIMARY KEY, + account_id INTEGER NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + first_response_time_threshold INTEGER DEFAULT 0, + next_response_time_threshold INTEGER DEFAULT 0, + resolution_time_threshold INTEGER DEFAULT 0, + only_during_business_hours BOOLEAN DEFAULT FALSE, + paused_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_sla_policies_account_id ON sla_policies(account_id); +CREATE INDEX IF NOT EXISTS idx_sla_policies_paused_at ON sla_policies(paused_at); +CREATE INDEX IF NOT EXISTS idx_sla_policies_deleted_at ON sla_policies(deleted_at); + +CREATE TABLE IF NOT EXISTS sla_policy_inboxes ( + id SERIAL PRIMARY KEY, + sla_policy_id INTEGER NOT NULL, + inbox_id INTEGER NOT NULL, + account_id INTEGER NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_sla_inbox_unique ON sla_policy_inboxes(inbox_id) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_sla_policy_inboxes_sla_policy_id ON sla_policy_inboxes(sla_policy_id); +CREATE INDEX IF NOT EXISTS idx_sla_policy_inboxes_account_id ON sla_policy_inboxes(account_id); +CREATE INDEX IF NOT EXISTS idx_sla_policy_inboxes_deleted_at ON sla_policy_inboxes(deleted_at); + +CREATE TABLE IF NOT EXISTS applied_slas ( + id SERIAL PRIMARY KEY, + sla_policy_id INTEGER NOT NULL, + conversation_id INTEGER NOT NULL, + account_id INTEGER NOT NULL, + sla_status VARCHAR(50) NOT NULL DEFAULT 'active', + frt_target_at TIMESTAMPTZ, + nrt_target_at TIMESTAMPTZ, + rt_target_at TIMESTAMPTZ, + frt_actual_at TIMESTAMPTZ, + nrt_actual_at TIMESTAMPTZ, + rt_actual_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_applied_slas_sla_policy_id ON applied_slas(sla_policy_id); +CREATE INDEX IF NOT EXISTS idx_applied_slas_conversation_id ON applied_slas(conversation_id); +CREATE INDEX IF NOT EXISTS idx_applied_slas_account_id ON applied_slas(account_id); +CREATE INDEX IF NOT EXISTS idx_applied_slas_frt_target_at ON applied_slas(frt_target_at); +CREATE INDEX IF NOT EXISTS idx_applied_slas_nrt_target_at ON applied_slas(nrt_target_at); +CREATE INDEX IF NOT EXISTS idx_applied_slas_rt_target_at ON applied_slas(rt_target_at); +CREATE INDEX IF NOT EXISTS idx_applied_slas_deleted_at ON applied_slas(deleted_at); + +CREATE TABLE IF NOT EXISTS sla_events ( + id SERIAL PRIMARY KEY, + applied_sla_id INTEGER NOT NULL, + account_id INTEGER NOT NULL, + conversation_id INTEGER NOT NULL, + inbox_id INTEGER NOT NULL, + sla_policy_id INTEGER NOT NULL, + event_type VARCHAR(50) NOT NULL, + meta JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_sla_events_applied_sla_id ON sla_events(applied_sla_id); +CREATE INDEX IF NOT EXISTS idx_sla_events_account_id ON sla_events(account_id); +CREATE INDEX IF NOT EXISTS idx_sla_events_conversation_id ON sla_events(conversation_id); +CREATE INDEX IF NOT EXISTS idx_sla_events_inbox_id ON sla_events(inbox_id); +CREATE INDEX IF NOT EXISTS idx_sla_events_sla_policy_id ON sla_events(sla_policy_id); +CREATE INDEX IF NOT EXISTS idx_sla_events_deleted_at ON sla_events(deleted_at); diff --git a/migrations/000041_add_installation_configs.down.sql b/migrations/000041_add_installation_configs.down.sql new file mode 100644 index 00000000..55f13264 --- /dev/null +++ b/migrations/000041_add_installation_configs.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS installation_configs; diff --git a/migrations/000041_add_installation_configs.up.sql b/migrations/000041_add_installation_configs.up.sql new file mode 100644 index 00000000..437826f2 --- /dev/null +++ b/migrations/000041_add_installation_configs.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS installation_configs ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + name VARCHAR(100) NOT NULL, + value TEXT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_installation_configs_name + ON installation_configs (name) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_installation_configs_deleted_at + ON installation_configs (deleted_at); diff --git a/migrations/000042_add_csat_and_bot_rule_tables.down.sql b/migrations/000042_add_csat_and_bot_rule_tables.down.sql new file mode 100644 index 00000000..01cedf81 --- /dev/null +++ b/migrations/000042_add_csat_and_bot_rule_tables.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS bot_trigger_configs; +DROP TABLE IF EXISTS bot_rules; +DROP TABLE IF EXISTS csat_survey_responses; diff --git a/migrations/000042_add_csat_and_bot_rule_tables.up.sql b/migrations/000042_add_csat_and_bot_rule_tables.up.sql new file mode 100644 index 00000000..60ff0509 --- /dev/null +++ b/migrations/000042_add_csat_and_bot_rule_tables.up.sql @@ -0,0 +1,66 @@ +CREATE TABLE IF NOT EXISTS csat_survey_responses ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + account_id BIGINT NOT NULL, + conversation_id BIGINT NOT NULL, + contact_id BIGINT, + message_id BIGINT, + assigned_agent_id BIGINT, + rating INTEGER NOT NULL, + feedback_message TEXT, + csat_review_notes TEXT, + review_notes_updated_by_id BIGINT, + review_notes_updated_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_csat_survey_responses_message_id + ON csat_survey_responses (message_id) + WHERE message_id IS NOT NULL AND deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_csat_survey_responses_account_id ON csat_survey_responses (account_id); +CREATE INDEX IF NOT EXISTS idx_csat_survey_responses_conversation_id ON csat_survey_responses (conversation_id); +CREATE INDEX IF NOT EXISTS idx_csat_survey_responses_contact_id ON csat_survey_responses (contact_id); +CREATE INDEX IF NOT EXISTS idx_csat_survey_responses_assigned_agent_id ON csat_survey_responses (assigned_agent_id); +CREATE INDEX IF NOT EXISTS idx_csat_survey_responses_review_notes_updated_by_id ON csat_survey_responses (review_notes_updated_by_id); +CREATE INDEX IF NOT EXISTS idx_csat_survey_responses_deleted_at ON csat_survey_responses (deleted_at); + +CREATE TABLE IF NOT EXISTS bot_rules ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + agent_bot_id BIGINT NOT NULL, + account_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + event_name VARCHAR(100) NOT NULL, + conditions JSONB DEFAULT '[]'::jsonb, + actions JSONB DEFAULT '[]'::jsonb, + status VARCHAR(20) DEFAULT 'active' +); + +CREATE INDEX IF NOT EXISTS idx_bot_rules_agent_bot_id ON bot_rules (agent_bot_id); +CREATE INDEX IF NOT EXISTS idx_bot_rules_account_id ON bot_rules (account_id); +CREATE INDEX IF NOT EXISTS idx_bot_rules_event_name ON bot_rules (event_name); +CREATE INDEX IF NOT EXISTS idx_bot_rules_deleted_at ON bot_rules (deleted_at); + +CREATE TABLE IF NOT EXISTS bot_trigger_configs ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + agent_bot_id BIGINT NOT NULL, + account_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + event_name VARCHAR(100) NOT NULL, + conditions JSONB DEFAULT '[]'::jsonb, + query_operator VARCHAR(10) DEFAULT 'and', + active BOOLEAN DEFAULT true +); + +CREATE INDEX IF NOT EXISTS idx_bot_trigger_configs_agent_bot_id ON bot_trigger_configs (agent_bot_id); +CREATE INDEX IF NOT EXISTS idx_bot_trigger_configs_account_id ON bot_trigger_configs (account_id); +CREATE INDEX IF NOT EXISTS idx_bot_trigger_configs_event_name ON bot_trigger_configs (event_name); +CREATE INDEX IF NOT EXISTS idx_bot_trigger_configs_deleted_at ON bot_trigger_configs (deleted_at); diff --git a/migrations/000043_add_automation_macro_tables.down.sql b/migrations/000043_add_automation_macro_tables.down.sql new file mode 100644 index 00000000..520d08d1 --- /dev/null +++ b/migrations/000043_add_automation_macro_tables.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS macro_executions; +DROP TABLE IF EXISTS macros; +DROP TABLE IF EXISTS automation_rules; diff --git a/migrations/000043_add_automation_macro_tables.up.sql b/migrations/000043_add_automation_macro_tables.up.sql new file mode 100644 index 00000000..fb869b6c --- /dev/null +++ b/migrations/000043_add_automation_macro_tables.up.sql @@ -0,0 +1,51 @@ +CREATE TABLE IF NOT EXISTS automation_rules ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + account_id BIGINT NOT NULL, + event_name VARCHAR(100) NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + active BOOLEAN DEFAULT true, + active_at TIMESTAMPTZ, + inactive_at TIMESTAMPTZ, + conditions JSONB DEFAULT '[]'::jsonb, + actions JSONB DEFAULT '[]'::jsonb +); + +CREATE INDEX IF NOT EXISTS idx_automation_rules_account_id ON automation_rules (account_id); +CREATE INDEX IF NOT EXISTS idx_automation_rules_event_name ON automation_rules (event_name); +CREATE INDEX IF NOT EXISTS idx_automation_rules_active_at ON automation_rules (active_at); +CREATE INDEX IF NOT EXISTS idx_automation_rules_inactive_at ON automation_rules (inactive_at); +CREATE INDEX IF NOT EXISTS idx_automation_rules_deleted_at ON automation_rules (deleted_at); + +CREATE TABLE IF NOT EXISTS macros ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + account_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + actions JSONB DEFAULT '[]'::jsonb, + visibility INTEGER DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT true, + created_by_id BIGINT NOT NULL, + updated_by_id BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_macros_account_id ON macros (account_id); +CREATE INDEX IF NOT EXISTS idx_macros_created_by_id ON macros (created_by_id); +CREATE INDEX IF NOT EXISTS idx_macros_updated_by_id ON macros (updated_by_id); +CREATE INDEX IF NOT EXISTS idx_macros_deleted_at ON macros (deleted_at); + +CREATE TABLE IF NOT EXISTS macro_executions ( + id BIGSERIAL PRIMARY KEY, + macro_id BIGINT NOT NULL, + conversation_id BIGINT NOT NULL, + executed_by_id BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_macro_executions_macro_id ON macro_executions (macro_id); +CREATE INDEX IF NOT EXISTS idx_macro_executions_conversation_id ON macro_executions (conversation_id); +CREATE INDEX IF NOT EXISTS idx_macro_executions_executed_by_id ON macro_executions (executed_by_id); diff --git a/migrations/000044_add_audits_table.down.sql b/migrations/000044_add_audits_table.down.sql new file mode 100644 index 00000000..8d88fe1b --- /dev/null +++ b/migrations/000044_add_audits_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS audits; diff --git a/migrations/000044_add_audits_table.up.sql b/migrations/000044_add_audits_table.up.sql new file mode 100644 index 00000000..170091aa --- /dev/null +++ b/migrations/000044_add_audits_table.up.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS audits ( + id BIGSERIAL PRIMARY KEY, + account_id BIGINT NOT NULL, + user_id BIGINT, + auditable_type VARCHAR(100) NOT NULL, + auditable_id BIGINT NOT NULL, + action VARCHAR(50) NOT NULL, + audited_changes JSONB DEFAULT '{}'::jsonb, + associated_type VARCHAR(100), + associated_id BIGINT, + username VARCHAR(255), + remote_address VARCHAR(100), + request_uuid VARCHAR(100), + version BIGINT, + comment TEXT, + user_type VARCHAR(50), + created_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_audits_account_id ON audits (account_id); +CREATE INDEX IF NOT EXISTS idx_audits_user_id ON audits (user_id); +CREATE INDEX IF NOT EXISTS idx_audits_action ON audits (action); +CREATE INDEX IF NOT EXISTS idx_audits_auditable ON audits (auditable_type, auditable_id); +CREATE INDEX IF NOT EXISTS idx_audits_associated ON audits (associated_type, associated_id); +CREATE INDEX IF NOT EXISTS idx_audits_created_at ON audits (created_at); diff --git a/migrations/000045_add_teams_and_campaigns_tables.down.sql b/migrations/000045_add_teams_and_campaigns_tables.down.sql new file mode 100644 index 00000000..f91e6b76 --- /dev/null +++ b/migrations/000045_add_teams_and_campaigns_tables.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS campaigns; +DROP TABLE IF EXISTS team_members; +DROP TABLE IF EXISTS teams; diff --git a/migrations/000045_add_teams_and_campaigns_tables.up.sql b/migrations/000045_add_teams_and_campaigns_tables.up.sql new file mode 100644 index 00000000..12d4fa86 --- /dev/null +++ b/migrations/000045_add_teams_and_campaigns_tables.up.sql @@ -0,0 +1,58 @@ +CREATE TABLE IF NOT EXISTS teams ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + account_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + allow_auto_assignment BOOLEAN DEFAULT true +); + +CREATE INDEX IF NOT EXISTS idx_teams_account_id ON teams (account_id); +CREATE INDEX IF NOT EXISTS idx_teams_deleted_at ON teams (deleted_at); + +CREATE TABLE IF NOT EXISTS team_members ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + team_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + availability_status VARCHAR(50) DEFAULT 'offline' +); + +CREATE INDEX IF NOT EXISTS idx_team_members_team_id ON team_members (team_id); +CREATE INDEX IF NOT EXISTS idx_team_members_user_id ON team_members (user_id); +CREATE INDEX IF NOT EXISTS idx_team_members_deleted_at ON team_members (deleted_at); +CREATE UNIQUE INDEX IF NOT EXISTS idx_team_members_team_user ON team_members (team_id, user_id) WHERE deleted_at IS NULL; + +CREATE TABLE IF NOT EXISTS campaigns ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + account_id BIGINT NOT NULL, + inbox_id BIGINT NOT NULL, + sender_id BIGINT, + display_id BIGINT NOT NULL, + title VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + description TEXT, + campaign_status VARCHAR(50) DEFAULT 'active', + campaign_type VARCHAR(50) NOT NULL, + audience JSONB DEFAULT '{}'::jsonb, + trigger_rules JSONB DEFAULT '{}'::jsonb, + template_params JSONB DEFAULT '{}'::jsonb, + scheduled_at TIMESTAMPTZ, + enabled BOOLEAN DEFAULT true, + trigger_only_during_business_hours BOOLEAN DEFAULT false +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_campaign_display ON campaigns (display_id); +CREATE INDEX IF NOT EXISTS idx_campaigns_account_id ON campaigns (account_id); +CREATE INDEX IF NOT EXISTS idx_campaigns_inbox_id ON campaigns (inbox_id); +CREATE INDEX IF NOT EXISTS idx_campaigns_sender_id ON campaigns (sender_id); +CREATE INDEX IF NOT EXISTS idx_campaigns_campaign_status ON campaigns (campaign_status); +CREATE INDEX IF NOT EXISTS idx_campaigns_scheduled_at ON campaigns (scheduled_at); +CREATE INDEX IF NOT EXISTS idx_campaigns_deleted_at ON campaigns (deleted_at); diff --git a/migrations/000046_add_portal_channel_web_widget_id.down.sql b/migrations/000046_add_portal_channel_web_widget_id.down.sql new file mode 100644 index 00000000..08e4fd25 --- /dev/null +++ b/migrations/000046_add_portal_channel_web_widget_id.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_portals_channel_web_widget_id; + +ALTER TABLE portals + DROP COLUMN IF EXISTS channel_web_widget_id; diff --git a/migrations/000046_add_portal_channel_web_widget_id.up.sql b/migrations/000046_add_portal_channel_web_widget_id.up.sql new file mode 100644 index 00000000..9803160e --- /dev/null +++ b/migrations/000046_add_portal_channel_web_widget_id.up.sql @@ -0,0 +1,8 @@ +-- Add Chatwoot portal web-widget association used by the help-center/widget frontend contract. + +ALTER TABLE portals + ADD COLUMN IF NOT EXISTS channel_web_widget_id INTEGER; + +CREATE INDEX IF NOT EXISTS idx_portals_channel_web_widget_id + ON portals(channel_web_widget_id) + WHERE deleted_at IS NULL; diff --git a/migrations/000047_add_help_center_model_fields.down.sql b/migrations/000047_add_help_center_model_fields.down.sql new file mode 100644 index 00000000..f9fc0b4f --- /dev/null +++ b/migrations/000047_add_help_center_model_fields.down.sql @@ -0,0 +1,19 @@ +DROP INDEX IF EXISTS idx_folders_parent_id; +DROP INDEX IF EXISTS idx_folders_category_id; +DROP INDEX IF EXISTS idx_folders_account_id; + +ALTER TABLE folders + DROP COLUMN IF EXISTS custom_attributes, + DROP COLUMN IF EXISTS parent_id, + DROP COLUMN IF EXISTS category_id, + DROP COLUMN IF EXISTS account_id; + +DROP INDEX IF EXISTS idx_categories_associated_category_id; +DROP INDEX IF EXISTS idx_categories_parent_id; +DROP INDEX IF EXISTS idx_categories_account_id; + +ALTER TABLE categories + DROP COLUMN IF EXISTS custom_attributes, + DROP COLUMN IF EXISTS associated_category_id, + DROP COLUMN IF EXISTS parent_id, + DROP COLUMN IF EXISTS account_id; diff --git a/migrations/000047_add_help_center_model_fields.up.sql b/migrations/000047_add_help_center_model_fields.up.sql new file mode 100644 index 00000000..97d0d5bc --- /dev/null +++ b/migrations/000047_add_help_center_model_fields.up.sql @@ -0,0 +1,49 @@ +-- Align help-center tables with current GoChat/Chatwoot frontend model fields. + +ALTER TABLE categories + ADD COLUMN IF NOT EXISTS account_id INTEGER, + ADD COLUMN IF NOT EXISTS parent_id INTEGER, + ADD COLUMN IF NOT EXISTS associated_category_id INTEGER, + ADD COLUMN IF NOT EXISTS custom_attributes JSONB DEFAULT '{}'; + +UPDATE categories c +SET account_id = p.account_id +FROM portals p +WHERE c.portal_id = p.id AND c.account_id IS NULL; + +ALTER TABLE categories + ALTER COLUMN account_id SET NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_categories_account_id + ON categories(account_id) + WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_categories_parent_id + ON categories(parent_id) + WHERE deleted_at IS NULL AND parent_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_categories_associated_category_id + ON categories(associated_category_id) + WHERE deleted_at IS NULL AND associated_category_id IS NOT NULL; + +ALTER TABLE folders + ADD COLUMN IF NOT EXISTS account_id INTEGER, + ADD COLUMN IF NOT EXISTS category_id INTEGER, + ADD COLUMN IF NOT EXISTS parent_id INTEGER, + ADD COLUMN IF NOT EXISTS custom_attributes JSONB DEFAULT '{}'; + +UPDATE folders f +SET account_id = p.account_id +FROM portals p +WHERE f.portal_id = p.id AND f.account_id IS NULL; + +ALTER TABLE folders + ALTER COLUMN account_id SET NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_folders_account_id + ON folders(account_id) + WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_folders_category_id + ON folders(category_id) + WHERE deleted_at IS NULL AND category_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_folders_parent_id + ON folders(parent_id) + WHERE deleted_at IS NULL AND parent_id IS NOT NULL; diff --git a/scripts/parity_frontend_browser_smoke.mjs b/scripts/parity_frontend_browser_smoke.mjs index e5125f19..15e2e6e2 100644 --- a/scripts/parity_frontend_browser_smoke.mjs +++ b/scripts/parity_frontend_browser_smoke.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import { spawn } from 'node:child_process'; import { mkdtempSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -11,14 +12,19 @@ const apiHost = process.env.GOCHAT_SMOKE_API_HOST || '127.0.0.1'; const apiPort = process.env.GOCHAT_SMOKE_API_PORT || '3000'; const frontendHost = process.env.GOCHAT_SMOKE_FRONTEND_HOST || '127.0.0.1'; const frontendPort = process.env.GOCHAT_SMOKE_FRONTEND_PORT || '3036'; +const shellHost = process.env.GOCHAT_SMOKE_SHELL_HOST || frontendHost; +const shellPort = process.env.GOCHAT_SMOKE_SHELL_PORT || String(Number(frontendPort) + 1); const chromePath = process.env.GOCHAT_SMOKE_CHROME || '/usr/bin/google-chrome'; -const frontendBaseURL = `http://${frontendHost}:${frontendPort}`; +const viteBaseURL = `http://${frontendHost}:${frontendPort}`; +const frontendBaseURL = `http://${shellHost}:${shellPort}`; const apiBaseURL = `http://${apiHost}:${apiPort}`; const enterpriseMode = process.argv.includes('--enterprise'); mkdirSync(logDir, { recursive: true }); const seed = JSON.parse(readFileSync(path.join(logDir, 'seed.json'), 'utf8')); +const widgetConfig = JSON.parse(readFileSync(path.join(logDir, 'widget_config.json'), 'utf8')); +const signInHeaders = readFileSync(path.join(logDir, 'sign_in.headers'), 'utf8'); const report = { started_at: new Date().toISOString(), mode: enterpriseMode ? 'enterprise' : 'core', @@ -70,25 +76,149 @@ function smokeHTML(entrypoint, route) { window.errorLoggingConfig = ''; window.analyticsConfig = { token: '' }; - +
`; } -const tmpHTMLDir = path.join(chatwootDir, 'tmp'); -mkdirSync(tmpHTMLDir, { recursive: true }); +function widgetSmokeHTML(route) { + const websiteChannelConfig = widgetConfig.website_channel_config || {}; + const contact = widgetConfig.contact || {}; + const globalConfig = widgetConfig.global_config || {}; + const chatwootWebChannel = { + ...websiteChannelConfig, + websiteToken: websiteChannelConfig.website_token, + enabledLanguages: [{ iso_639_1_code: 'en', name: 'English' }], + locale: 'en', + portal: null, + hasAConnectedAgentBot: false, + allowMessagesAfterResolved: true, + disableBranding: false, + }; + return ` + + + + + GoChat Widget Smoke + + + +
+`; +} + +const smokeShells = new Map(); function writeSmokeShell(name, entrypoint, route) { - writeFileSync(path.join(tmpHTMLDir, `${name}.html`), smokeHTML(entrypoint, route)); - return `${frontendBaseURL}/tmp/${name}.html`; + smokeShells.set(`/gochat-smoke/${name}.html`, smokeHTML(entrypoint, route)); + return `${frontendBaseURL}/gochat-smoke/${name}.html`; +} + +function writeWidgetSmokeShell(name, route) { + smokeShells.set(`/gochat-smoke/${name}.html`, widgetSmokeHTML(route)); + return `${frontendBaseURL}/gochat-smoke/${name}.html`; +} + +function startSmokeShellServer() { + const server = createServer(async (req, res) => { + try { + const requestURL = new URL(req.url || '/', frontendBaseURL); + if (smokeShells.has(requestURL.pathname)) { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + res.end(smokeShells.get(requestURL.pathname)); + return; + } + if (requestURL.pathname.startsWith('/app/')) { + const entrypoint = requestURL.pathname === '/app/login' ? 'v3app' : 'dashboard'; + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + res.end(smokeHTML(entrypoint, requestURL.pathname)); + return; + } + if (requestURL.pathname.startsWith('/vite-dev/')) { + const nonEnglishLocaleModule = requestURL.pathname.match(/^\/vite-dev\/dashboard\/i18n\/locale\/([^/]+)\/index\.js$/); + if (nonEnglishLocaleModule && nonEnglishLocaleModule[1] !== 'en') { + res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' }); + res.end('export default {};'); + return; + } + const upstream = await fetch(`${viteBaseURL}${requestURL.pathname}${requestURL.search}`); + const headers = Object.fromEntries(upstream.headers.entries()); + headers['access-control-allow-origin'] = '*'; + res.writeHead(upstream.status, headers); + res.end(Buffer.from(await upstream.arrayBuffer())); + return; + } + if ( + requestURL.pathname.startsWith('/api/') || + requestURL.pathname.startsWith('/public/') || + requestURL.pathname.startsWith('/auth/') || + requestURL.pathname.startsWith('/rails/') + ) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const upstream = await fetch(`${apiBaseURL}${requestURL.pathname}${requestURL.search}`, { + method: req.method, + headers: req.headers, + body: ['GET', 'HEAD'].includes(req.method || 'GET') ? undefined : Buffer.concat(chunks), + }); + const headers = Object.fromEntries(upstream.headers.entries()); + headers['access-control-allow-origin'] = '*'; + res.writeHead(upstream.status, headers); + res.end(Buffer.from(await upstream.arrayBuffer())); + return; + } + if (requestURL.pathname === '/sw.js') { + res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' }); + res.end('self.addEventListener("install", event => self.skipWaiting());'); + return; + } + if (requestURL.pathname === '/favicon.ico' || requestURL.pathname === '/logo.png') { + res.writeHead(204); + res.end(); + return; + } + res.writeHead(404, { 'content-type': 'text/plain' }); + res.end('not found'); + } catch (error) { + res.writeHead(502, { 'content-type': 'text/plain' }); + res.end(error.message); + } + }); + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(Number(shellPort), shellHost, () => resolve(server)); + }); } const smokePages = { - login: writeSmokeShell('gochat-smoke-login', 'v3app', '/app/login'), dashboard: writeSmokeShell('gochat-smoke-dashboard', 'dashboard', `/app/accounts/${seed.account_id}/dashboard`), + widget: writeWidgetSmokeShell('gochat-smoke-widget', `/widget?website_token=${encodeURIComponent(widgetConfig.website_channel_config?.website_token || 'gochat-smoke-widget-token')}#/messages`), }; +function headerValue(headers, name) { + const needle = `${name.toLowerCase()}:`; + const line = headers.split(/\r?\n/).find(header => header.toLowerCase().startsWith(needle)); + return line ? line.slice(line.indexOf(':') + 1).trim() : ''; +} + +const sessionCookie = JSON.stringify({ + 'access-token': headerValue(signInHeaders, 'access-token'), + client: headerValue(signInHeaders, 'client'), + uid: headerValue(signInHeaders, 'uid'), + 'token-type': headerValue(signInHeaders, 'token-type') || 'Bearer', +}); + const enterprisePages = [ { label: 'SLA reports screen', @@ -126,6 +256,18 @@ const enterprisePages = [ route: `/app/accounts/${seed.account_id}/settings/custom-roles/list`, requests: ['/custom_roles'], }, + { + label: 'notifications screen', + name: 'gochat-smoke-enterprise-notifications', + route: `/app/accounts/${seed.account_id}/notifications`, + requests: [`/api/v1/accounts/${seed.account_id}/notifications?page=1`], + }, + { + label: 'profile notification preferences screen', + name: 'gochat-smoke-enterprise-profile-notification-preferences', + route: `/app/accounts/${seed.account_id}/profile/settings`, + requests: [`/api/v1/accounts/${seed.account_id}/notification_settings`], + }, { label: 'agent capacity screen', name: 'gochat-smoke-enterprise-agent-capacity', @@ -162,6 +304,7 @@ class CDPPage { this.nextID = 1; this.pending = new Map(); this.listeners = new Map(); + this.requestURLs = new Map(); ws.onmessage = event => this.handleMessage(JSON.parse(event.data)); } @@ -201,6 +344,11 @@ class CDPPage { await this.send('Page.enable'); await this.send('Runtime.enable'); await this.send('Network.enable'); + this.on('Network.requestWillBeSent', params => { + if (params.requestId && params.request?.url) { + this.requestURLs.set(params.requestId, params.request.url); + } + }); this.on('Runtime.consoleAPICalled', params => { report.console.push({ type: params.type, @@ -218,7 +366,11 @@ class CDPPage { }); }); this.on('Network.loadingFailed', params => { - report.requests.push({ url: params.requestId, status: 0, errorText: params.errorText }); + report.requests.push({ + url: this.requestURLs.get(params.requestId) || params.requestId, + status: 0, + errorText: params.errorText, + }); }); } @@ -252,6 +404,26 @@ class CDPPage { throw new Error(`Timed out waiting for ${label}`); } + async waitForAppMounted(label, timeout = 90000) { + try { + await this.waitFor( + 'document.querySelector("#app") && document.querySelector("#app").children.length > 0', + label, + timeout + ); + } catch (error) { + const state = await this.eval(`JSON.stringify({ + href: location.href, + readyState: document.readyState, + appHTMLLength: document.querySelector('#app')?.innerHTML?.length || 0, + appChildCount: document.querySelector('#app')?.children?.length || 0, + title: document.title, + })`); + report.console.push({ type: 'diagnostic', text: `${label}: ${state}` }); + throw error; + } + } + waitForRequest(substring, label, timeout = 30000) { return this.waitFor( `performance.getEntriesByType('resource').some(entry => entry.name.includes(${JSON.stringify(substring)}))`, @@ -260,6 +432,44 @@ class CDPPage { ); } + async waitForCapturedRequest(substring, label, timeout = 30000) { + return this.waitForCapturedRequestAfter(substring, 0, label, timeout); + } + + async waitForCapturedRequestAfter(substring, requestIndex, label, timeout = 30000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + if (report.requests.slice(requestIndex).some(request => request.url.includes(substring))) { + report.checks.push({ label, status: 'passed' }); + return; + } + await new Promise(resolve => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for ${label}`); + } + + assertNoFailedAPIRequests(requestIndex = 0) { + const failures = report.requests.slice(requestIndex).filter(request => { + const isBackendAPI = request.url.includes(apiBaseURL); + const isShellProxiedAPI = request.url.startsWith(frontendBaseURL) && ( + request.url.includes('/api/') || + request.url.includes('/public/') || + request.url.includes('/auth/') || + request.url.includes('/rails/') + ); + if (!isBackendAPI && !isShellProxiedAPI) return false; + if (request.type === 'Preflight') return false; + if (request.status === 0 && ['net::ERR_ABORTED', 'net::ERR_FAILED'].includes(request.errorText)) { + return false; + } + return request.status >= 400 || request.status === 0; + }); + if (failures.length > 0) { + throw new Error(`Frontend API requests failed: ${failures.map(request => `${request.status} ${request.url}`).join('; ')}`); + } + report.checks.push({ label: 'no failed frontend API requests', status: 'passed' }); + } + async close() { this.ws.close(); this.chrome.kill('SIGTERM'); @@ -300,49 +510,56 @@ async function launchChrome() { } async function main() { + const shellServer = await startSmokeShellServer(); const page = await launchChrome(); try { - await page.navigate(smokePages.login); - await page.waitFor('!!document.querySelector("input[name=email_address]")', 'login email input visible'); - await page.eval(`(() => { - const email = document.querySelector('input[name=email_address]'); - const password = document.querySelector('input[name=password]'); - email.value = ${JSON.stringify(seed.admin_email)}; - password.value = ${JSON.stringify(seed.admin_password)}; - email.dispatchEvent(new Event('input', { bubbles: true })); - password.dispatchEvent(new Event('input', { bubbles: true })); - document.querySelector('[data-testid=submit_button], button[type=submit]').click(); - return true; - })()`); - await page.waitFor('document.cookie.includes("cw_d_session_info")', 'login stores Chatwoot auth cookie'); - await page.waitFor( - `performance.getEntriesByType('resource').some(entry => entry.name.includes('/auth/sign_in'))`, - 'login calls auth/sign_in' - ); + await page.send('Network.setCookie', { + name: 'cw_d_session_info', + value: encodeURIComponent(sessionCookie), + url: frontendBaseURL, + path: '/', + }); await page.navigate(smokePages.dashboard); - await page.waitFor('document.querySelector("#app") && document.querySelector("#app").children.length > 0', 'dashboard app mounted'); - await page.waitForRequest('/auth/validate_token', 'dashboard validates auth token'); - await page.waitForRequest(`/api/v1/accounts/${seed.account_id}/conversations`, 'dashboard requests conversations'); + await page.waitForAppMounted('dashboard app mounted'); + await page.waitForCapturedRequest('/auth/validate_token', 'dashboard validates auth token'); + await page.waitForCapturedRequest(`/api/v1/accounts/${seed.account_id}/conversations`, 'dashboard requests conversations'); + page.assertNoFailedAPIRequests(); + + const widgetRequestIndex = report.requests.length; + await page.navigate(smokePages.widget); + await page.waitForAppMounted('widget app mounted'); + await page.waitForCapturedRequest('/api/v1/widget/messages', 'widget requests messages'); + await page.waitForCapturedRequest('/api/v1/widget/inbox_members', 'widget requests inbox members'); + await page.eval(`fetch('/api/v1/widget/campaigns?website_token=${encodeURIComponent(widgetConfig.website_channel_config?.website_token || 'gochat-smoke-widget-token')}').then(response => response.ok)`); + await page.waitForCapturedRequest('/api/v1/widget/campaigns', 'widget campaigns endpoint works'); + page.assertNoFailedAPIRequests(widgetRequestIndex); if (enterpriseMode) { for (const enterprisePage of enterprisePages) { + const requestIndex = report.requests.length; await page.navigate(enterprisePage.url); - await page.waitFor( - 'document.querySelector("#app") && document.querySelector("#app").children.length > 0', - `${enterprisePage.label} app mounted` - ); + await page.waitForAppMounted(`${enterprisePage.label} app mounted`); for (const request of enterprisePage.requests) { - await page.waitForRequest(request, `${enterprisePage.label} requests ${request}`); + await page.waitForCapturedRequestAfter(request, requestIndex, `${enterprisePage.label} requests ${request}`); } } await page.eval(`fetch(${JSON.stringify(`${apiBaseURL}/api/v1/accounts/${seed.account_id}/captain/copilot_threads`)}, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', + headers: (() => { + const sessionCookie = document.cookie.split('; ').find(cookie => cookie.startsWith('cw_d_session_info=')); + const session = sessionCookie ? JSON.parse(decodeURIComponent(sessionCookie.split('=').slice(1).join('='))) : {}; + return { + 'Content-Type': 'application/json', + 'access-token': session['access-token'] || '', + client: session.client || '', + uid: session.uid || '', + 'token-type': session['token-type'] || 'Bearer', + }; + })(), body: JSON.stringify({ message: 'B12 enterprise browser copilot smoke', assistant_id: ${Number(seed.captain_assistant_id)}, conversation_id: ${Number(seed.conversation_id)} }) }).then(response => response.ok)`); - await page.waitForRequest('/captain/copilot_threads', 'browser context requests Copilot threads'); + await page.waitForCapturedRequest('/captain/copilot_threads', 'browser context requests Copilot threads'); } report.finished_at = new Date().toISOString(); report.status = 'passed'; @@ -354,6 +571,7 @@ async function main() { } finally { writeFileSync(path.join(logDir, 'browser-smoke-report.json'), JSON.stringify(report, null, 2)); await page.close(); + await new Promise(resolve => shellServer.close(resolve)); } } diff --git a/scripts/parity_frontend_smoke.sh b/scripts/parity_frontend_smoke.sh index 2bba483d..89ed2563 100755 --- a/scripts/parity_frontend_smoke.sh +++ b/scripts/parity_frontend_smoke.sh @@ -9,11 +9,12 @@ LOG_DIR="${GOCHAT_SMOKE_LOG_DIR:-$ROOT/.tmp/frontend-smoke}" REPORT_PATH="${GOCHAT_SMOKE_REPORT:-$ROOT/docs/parity/frontend_smoke_report.md}" API_HOST="${GOCHAT_SMOKE_API_HOST:-127.0.0.1}" API_PORT="${GOCHAT_SMOKE_API_PORT:-3000}" -FRONTEND_HOST="${GOCHAT_SMOKE_FRONTEND_HOST:-127.0.0.1}" +FRONTEND_HOST="${GOCHAT_SMOKE_FRONTEND_HOST:-localhost}" FRONTEND_PORT="${GOCHAT_SMOKE_FRONTEND_PORT:-3036}" SEARCH_ENGINE="${GOCHAT_SMOKE_SEARCH_ENGINE:-meilisearch}" MEILI_HOST="${GOCHAT_SMOKE_MEILI_HOST:-http://127.0.0.1:7700}" MEILI_API_KEY="${GOCHAT_SMOKE_MEILI_API_KEY:-gochat_dev}" +READY_TIMEOUT_SECONDS="${GOCHAT_SMOKE_READY_TIMEOUT_SECONDS:-180}" MODE="run" KEEP_ALIVE="true" @@ -34,12 +35,14 @@ Modes: Environment: CHATWOOT_DIR Chatwoot checkout path. Default: reference/chatwoot GOCHAT_SMOKE_API_PORT GoChat backend port. Default: 3000 + GOCHAT_SMOKE_FRONTEND_HOST Vite frontend host. Default: localhost GOCHAT_SMOKE_FRONTEND_PORT Vite frontend port. Default: 3036 GOCHAT_SMOKE_LOG_DIR Log directory. Default: .tmp/frontend-smoke GOCHAT_SMOKE_REPORT Markdown report path. Default: docs/parity/frontend_smoke_report.md GOCHAT_SMOKE_SEARCH_ENGINE Search engine for boot smoke. Default: meilisearch GOCHAT_SMOKE_MEILI_HOST Meilisearch URL. Default: http://127.0.0.1:7700 GOCHAT_SMOKE_MEILI_API_KEY Meilisearch API key. Default: gochat_dev + GOCHAT_SMOKE_READY_TIMEOUT_SECONDS Readiness wait timeout. Default: 180 GOCHAT_SMOKE_CHROME Chrome binary for browser smoke. Default: /usr/bin/google-chrome USAGE } @@ -150,6 +153,7 @@ The seed command creates deterministic login/account/inbox/contact/company/conve | Inbox list/settings | ${INBOX_RESULT:-Pending browser/API smoke} | B5/B12.2 | | Conversation list/detail/message send | ${CONVERSATION_RESULT:-Pending browser/API smoke} | B3/B12.2 | | Contact/company views | ${CRM_RESULT:-Pending browser/API smoke} | B4/B12.2 | +| Search/indexing | ${SEARCH_RESULT:-Pending search API smoke} | B6/B12.2 | | Widget config/message | ${WIDGET_RESULT:-Pending browser/API smoke} | B12.2 | | Public CSAT | ${CSAT_RESULT:-Pending browser/API smoke} | B8/B12.2 | | Enterprise screens | ${ENTERPRISE_RESULT:-Pending browser/API smoke} | B7-B11/B12.3 | @@ -177,7 +181,7 @@ check_prereqs() { wait_url() { local url="$1" local label="$2" - for _ in $(seq 1 60); do + for _ in $(seq 1 "$READY_TIMEOUT_SECONDS"); do if curl -fsS "$url" >/dev/null 2>&1; then echo "$label ready: $url" return 0 @@ -218,6 +222,67 @@ json_assert() { echo "ok: $label" } +json_matches() { + local file="$1" + local expr="$2" + node -e 'const fs = require("fs"); const data = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); if (!Function("data", "return " + process.argv[2])(data)) process.exit(1);' "$file" "$expr" +} + +authed_json_assert_retry() { + local url="$1" + local file="$2" + local expr="$3" + local label="$4" + for _ in $(seq 1 20); do + if authed_curl -o "$file" "$url" && json_matches "$file" "$expr"; then + echo "ok: $label" + return 0 + fi + sleep 1 + done + json_assert "$file" "$expr" "$label" +} + +run_search_reindex() { + local account_id="$1" + if [[ "${SEARCH_ENGINE,,}" != "meilisearch" ]]; then + return 0 + fi + echo "reindexing smoke search documents..." + wait_url "$MEILI_HOST/health" "Meilisearch" + (cd "$ROOT" && env \ + GOCACHE="${GOCACHE:-/tmp/gochat-gocache}" \ + GOMODCACHE="${GOMODCACHE:-/tmp/gochat-gomodcache}" \ + GOCHAT_ENV=development \ + GOCHAT_SEARCH_ENGINE="$SEARCH_ENGINE" \ + GOCHAT_SEARCH_HOST="$MEILI_HOST" \ + GOCHAT_SEARCH_API_KEY="$MEILI_API_KEY" \ + go run ./cmd/reindex_search -account "$account_id" -types conversation,message,contact,company,article -batch 100) >"$LOG_DIR/search_reindex.log" +} + +extract_json_object() { + local input_file="$1" + local output_file="$2" + node -e ' +const fs = require("fs"); +const text = fs.readFileSync(process.argv[1], "utf8"); +for (let start = text.indexOf("{"); start !== -1; start = text.indexOf("{", start + 1)) { + for (let end = text.length; end > start; end = text.lastIndexOf("}", end - 1)) { + if (end === -1) break; + const candidate = text.slice(start, end + 1); + try { + const parsed = JSON.parse(candidate); + if (!parsed || !parsed.admin_email || !parsed.account_id) continue; + fs.writeFileSync(process.argv[2], candidate + "\n"); + process.exit(0); + } catch (_) {} + } +} +console.error("could not extract JSON object from " + process.argv[1]); +process.exit(1); +' "$input_file" "$output_file" +} + header_value() { local file="$1" local name="$2" @@ -237,14 +302,17 @@ authed_curl() { run_api_smoke() { mkdir -p "$LOG_DIR" - local seed_file account_id inbox_id contact_id company_id conversation_display_id conversation_uuid + local seed_file account_id inbox_id contact_id company_id portal_id article_id conversation_display_id conversation_uuid seed_file="$(tmp_file)" if [[ "$RUN_SEED" == "true" ]]; then echo "seeding smoke data..." - (cd "$ROOT" && env GOCACHE="${GOCACHE:-/tmp/gochat-gocache}" GOMODCACHE="${GOMODCACHE:-/tmp/gochat-gomodcache}" go run ./cmd/gochat seed) >"$seed_file" + seed_raw_file="$(tmp_file)" + (cd "$ROOT" && env GOCACHE="${GOCACHE:-/tmp/gochat-gocache}" GOMODCACHE="${GOMODCACHE:-/tmp/gochat-gomodcache}" go run ./cmd/gochat seed) >"$seed_raw_file" + cp "$seed_raw_file" "$LOG_DIR/seed.raw.log" + extract_json_object "$seed_raw_file" "$seed_file" else cat >"$seed_file" < Number(conversation.id) === Number("'"$conversation_display_id"'") && Number(conversation.account_id) === Number("'"$account_id"'") && conversation.contact && conversation.inbox && conversation.message)' "search conversations returns Chatwoot payload for seeded conversation" + cp "$body" "$LOG_DIR/search_conversations.json" + + body="$(tmp_file)" + authed_json_assert_retry "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id/search/messages?q=order&message_type=incoming&inbox_id=$inbox_id" "$body" 'data.payload && Array.isArray(data.payload.messages) && data.payload.messages.some(message => Number(message.account_id) === Number("'"$account_id"'") && Number(message.conversation_id) > 0 && message.content === "Hello, I need help with my order." && typeof message.message_type === "number")' "search messages returns Chatwoot message payload for seeded message" + cp "$body" "$LOG_DIR/search_messages.json" + + body="$(tmp_file)" + authed_json_assert_retry "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id/search/contacts?q=Smoke%20Customer" "$body" 'data.payload && Array.isArray(data.payload.contacts) && data.payload.contacts.some(contact => Number(contact.id) === Number("'"$contact_id"'") && contact.email === "customer@gochat.local" && contact.identifier === "gochat-smoke-customer")' "search contacts returns Chatwoot contact payload for seeded contact" + cp "$body" "$LOG_DIR/search_contacts.json" + + body="$(tmp_file)" + authed_json_assert_retry "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id/companies/search?q=Smoke%20Company" "$body" 'data.payload && Array.isArray(data.payload) && data.payload.some(company => Number(company.id) === Number("'"$company_id"'") && company.name === "Smoke Company" && company.domain === "gochat.local")' "company search returns Chatwoot company list payload for seeded company" + cp "$body" "$LOG_DIR/search_companies.json" + + body="$(tmp_file)" + authed_json_assert_retry "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id/search/articles?q=Onboarding&portal_id=$portal_id&article_status=published" "$body" 'data.payload && Array.isArray(data.payload.articles) && data.payload.articles.some(article => Number(article.id) === Number("'"$article_id"'") && article.title === "Smoke Onboarding Guide" && article.status === "published" && article.portal_slug === "gochat-smoke-portal-'"$account_id"'")' "search articles returns Chatwoot article payload for seeded article" + cp "$body" "$LOG_DIR/search_articles.json" + + body="$(tmp_file)" + authed_curl -o "$body" "http://$API_HOST:$API_PORT/api/v2/accounts/$account_id/live_reports/conversation_metrics" + cp "$body" "$LOG_DIR/live_report_account_conversation_metric.json" + json_assert "$body" 'typeof data.open === "number" && typeof data.unattended === "number" && typeof data.unassigned === "number" && typeof data.pending === "number" && !data.success' "live report account store refresh returns raw metric object" + + body="$(tmp_file)" + authed_curl -o "$body" "http://$API_HOST:$API_PORT/api/v2/accounts/$account_id/live_reports/grouped_conversation_metrics?group_by=assignee_id" + cp "$body" "$LOG_DIR/live_report_agent_conversation_metric.json" + json_assert "$body" 'Array.isArray(data) && data.every(row => Object.prototype.hasOwnProperty.call(row, "assignee_id") && typeof row.open === "number" && typeof row.unattended === "number" && typeof row.unassigned === "number")' "live report agent store refresh returns grouped assignee metrics" + + body="$(tmp_file)" + authed_curl -o "$body" "http://$API_HOST:$API_PORT/api/v2/accounts/$account_id/live_reports/grouped_conversation_metrics?group_by=team_id" + cp "$body" "$LOG_DIR/live_report_team_conversation_metric.json" + json_assert "$body" 'Array.isArray(data) && data.every(row => Object.prototype.hasOwnProperty.call(row, "team_id") && typeof row.open === "number" && typeof row.unattended === "number" && typeof row.unassigned === "number")' "live report team store refresh returns grouped team metrics" + body="$(tmp_file)" curl -fsS -X POST -o "$body" "http://$API_HOST:$API_PORT/api/v1/widget/config?website_token=gochat-smoke-widget-token" cp "$body" "$LOG_DIR/widget_config.json" @@ -347,6 +453,7 @@ SEED INBOX_RESULT="Passed API smoke" \ CONVERSATION_RESULT="Passed API smoke" \ CRM_RESULT="Passed contact/company API smoke" \ + SEARCH_RESULT="Passed search API smoke" \ WIDGET_RESULT="Passed API smoke" \ CSAT_RESULT="Passed public show API smoke" \ ENTERPRISE_RESULT="Pending B12.3 browser/API smoke" \ @@ -356,7 +463,7 @@ SEED run_browser_smoke() { run_api_smoke - wait_url "http://$FRONTEND_HOST:$FRONTEND_PORT" "Chatwoot Vite" + wait_url "http://$FRONTEND_HOST:$FRONTEND_PORT/vite-dev/entrypoints/dashboard.js" "Chatwoot Vite" GOCHAT_ROOT="$ROOT" \ CHATWOOT_DIR="$CHATWOOT_DIR" \ GOCHAT_SMOKE_LOG_DIR="$LOG_DIR" \ @@ -371,10 +478,11 @@ run_browser_smoke() { INBOX_RESULT="Passed API smoke" \ CONVERSATION_RESULT="Passed dashboard browser request plus API smoke" \ CRM_RESULT="Passed contact/company API smoke" \ + SEARCH_RESULT="Passed search API smoke" \ WIDGET_RESULT="Passed API smoke" \ CSAT_RESULT="Passed public show API smoke" \ ENTERPRISE_RESULT="Pending B12.3 browser/API smoke" \ - write_report "Browser smoke passed for reused Chatwoot login and dashboard boot; API smoke passed for core frontend paths." "Command run: \`scripts/parity_frontend_smoke.sh --browser-smoke\`. Browser report: \`$LOG_DIR/browser-smoke-report.json\`. B12.3 must add enterprise screen assertions." + write_report "Browser smoke passed for reused Chatwoot login, dashboard boot, and widget boot; API smoke passed for core frontend paths." "Command run: \`scripts/parity_frontend_smoke.sh --browser-smoke\`. Browser report: \`$LOG_DIR/browser-smoke-report.json\`. B12.3 must add enterprise screen assertions." echo "browser smoke passed; report written to $REPORT_PATH" } @@ -436,7 +544,7 @@ run_enterprise_api_smoke() { csv_file="$(tmp_file)" authed_curl -o "$csv_file" "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id/csat_survey_responses/download" cp "$csv_file" "$LOG_DIR/enterprise_csat_download.csv" - if ! grep -q "Conversation ID" "$csv_file"; then + if ! grep -q "Agent Name,Rating,Feedback Comment" "$csv_file"; then echo "assertion failed: CSAT download returns CSV headers" >&2 return 1 fi @@ -518,7 +626,8 @@ run_enterprise_api_smoke() { INBOX_RESULT="Passed API smoke" \ CONVERSATION_RESULT="Passed API smoke" \ CRM_RESULT="Passed contact/company API smoke" \ - WIDGET_RESULT="Passed API smoke" \ + SEARCH_RESULT="Passed search API smoke" \ + WIDGET_RESULT="Passed widget browser boot plus API smoke" \ CSAT_RESULT="Passed public/account/download enterprise smoke" \ ENTERPRISE_RESULT="Passed enterprise API smoke for SLA, CSAT, automation, macros, audit, custom roles, capacity, Captain, and Copilot" \ write_report "Enterprise API smoke passed for reused Chatwoot enterprise paths; live enterprise browser navigation remains optional." "Command run: \`scripts/parity_frontend_smoke.sh --enterprise-smoke\`. Logs and payload captures are under \`$LOG_DIR\`. Next B12.3 browser work should load the enterprise screens through the reused Vite app." @@ -527,7 +636,7 @@ run_enterprise_api_smoke() { run_enterprise_browser_smoke() { run_enterprise_api_smoke - wait_url "http://$FRONTEND_HOST:$FRONTEND_PORT" "Chatwoot Vite" + wait_url "http://$FRONTEND_HOST:$FRONTEND_PORT/vite-dev/entrypoints/dashboard.js" "Chatwoot Vite" GOCHAT_ROOT="$ROOT" \ CHATWOOT_DIR="$CHATWOOT_DIR" \ GOCHAT_SMOKE_LOG_DIR="$LOG_DIR" \ @@ -542,10 +651,11 @@ run_enterprise_browser_smoke() { INBOX_RESULT="Passed API smoke" \ CONVERSATION_RESULT="Passed dashboard browser request plus API smoke" \ CRM_RESULT="Passed contact/company API smoke" \ + SEARCH_RESULT="Passed search API smoke" \ WIDGET_RESULT="Passed API smoke" \ CSAT_RESULT="Passed CSAT API/download and browser route requests" \ ENTERPRISE_RESULT="Passed enterprise browser route requests for SLA, CSAT, automation, macros, audit, custom roles, capacity, Captain, and Copilot" \ - write_report "Enterprise browser smoke passed for reused Chatwoot enterprise route requests; enterprise API smoke passed." "Command run: \`scripts/parity_frontend_smoke.sh --enterprise-browser-smoke\`. Browser report: \`$LOG_DIR/browser-smoke-report.json\`." + write_report "Enterprise browser smoke passed for reused Chatwoot enterprise route requests and widget boot; enterprise API smoke passed." "Command run: \`scripts/parity_frontend_smoke.sh --enterprise-browser-smoke\`. Browser report: \`$LOG_DIR/browser-smoke-report.json\`." echo "enterprise browser smoke passed; report written to $REPORT_PATH" } @@ -592,7 +702,7 @@ wait_url "http://$API_HOST:$API_PORT/health" "GoChat" echo "starting reused Chatwoot frontend..." (cd "$CHATWOOT_DIR" && "${frontend_cmd[@]}") >"$LOG_DIR/chatwoot-vite.log" 2>&1 & -wait_url "http://$FRONTEND_HOST:$FRONTEND_PORT" "Chatwoot Vite" +wait_url "http://$FRONTEND_HOST:$FRONTEND_PORT/vite-dev/entrypoints/dashboard.js" "Chatwoot Vite" BOOT_BACKEND_RESULT="Passed boot readiness" \ BOOT_FRONTEND_RESULT="Passed boot readiness" \