From c3806b701a8e03c414397a6bec7c10f94fa7c0a5 Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 14 Jul 2026 12:11:52 +0800 Subject: [PATCH] feat(parity): align Chatwoot 4.15.1 contracts --- backend/cmd/gochat/main.go | 21 +- backend/cmd/reindex_search/main.go | 7 +- backend/cmd/route_parity/main.go | 19 +- backend/cmd/route_parity/main_test.go | 52 ++ backend/internal/app/app.go | 2 + backend/internal/app/bootstrap.go | 9 +- backend/internal/auth/jwt.go | 9 +- backend/internal/auth/refresh_store.go | 72 +- backend/internal/auth/refresh_store_test.go | 27 + .../handler/api/v1/account_handler.go | 17 + .../handler/api/v1/account_handler_test.go | 22 + .../handler/api/v1/analytics_handler.go | 58 ++ .../handler/api/v1/analytics_handler_test.go | 162 ++++ .../handler/api/v1/article_handler.go | 4 +- .../api/v1/assignable_agent_handler.go | 29 +- .../api/v1/assignable_agent_handler_test.go | 24 + .../internal/handler/api/v1/auth_handler.go | 24 +- .../handler/api/v1/auth_handler_test.go | 9 +- .../api/v1/captain_assistant_handler.go | 94 +++ .../api/v1/captain_assistant_handler_test.go | 143 +++- .../handler/api/v1/category_handler.go | 3 + .../api/v1/chatwoot_415_serializer_test.go | 83 ++ .../handler/api/v1/conversation_handler.go | 14 + .../api/v1/conversation_handler_crud_test.go | 43 + .../handler/api/v1/conversation_serializer.go | 19 + .../api/v1/enterprise_account_handler.go | 39 +- .../api/v1/enterprise_account_handler_test.go | 69 +- .../internal/handler/api/v1/inbox_handler.go | 25 + .../handler/api/v1/inbox_serializer.go | 2 + .../internal/handler/api/v1/portal_handler.go | 9 +- .../handler/api/v1/profile_handler.go | 40 + .../handler/api/v1/profile_handler_test.go | 63 +- .../handler/api/v1/sla_policy_handler.go | 10 + .../internal/handler/api/v1/team_handler.go | 2 + .../handler/api/v1/whatsapp_call_handler.go | 57 ++ .../api/v1/whatsapp_call_handler_test.go | 69 +- backend/internal/middleware/account_scope.go | 35 +- .../internal/middleware/account_scope_test.go | 37 + backend/internal/middleware/auth.go | 20 + backend/internal/middleware/auth_test.go | 30 + backend/internal/middleware/csrf.go | 29 +- backend/internal/middleware/csrf_test.go | 16 + backend/internal/model/assignment_policy.go | 41 +- backend/internal/model/call.go | 1 + .../internal/model/captain_message_report.go | 18 + backend/internal/model/category.go | 37 +- backend/internal/model/team.go | 16 +- backend/internal/model/user_session.go | 26 + backend/internal/repository/account_repo.go | 7 + .../repository/captain_assistant_repo.go | 2 + .../internal/repository/conversation_repo.go | 15 +- backend/internal/repository/user_repo.go | 9 +- backend/internal/router/router.go | 38 +- backend/internal/service/account_service.go | 161 +++- backend/internal/service/analytics_service.go | 313 +++++++ .../service/assignable_agent_service.go | 15 + .../service/assignment_policy_service.go | 7 + backend/internal/service/auth_service.go | 103 ++- backend/internal/service/auth_service_test.go | 2 +- .../captain_assistant_response_service.go | 4 +- .../service/captain_assistant_service.go | 353 +++++++- .../service/captain_conversation_service.go | 2 +- .../captain_conversation_worker_test.go | 2 +- backend/internal/service/category_service.go | 9 + .../internal/service/conversation_service.go | 38 +- .../service/enterprise_billing_worker.go | 343 ++++++++ .../service/enterprise_billing_worker_test.go | 130 +++ backend/internal/service/inbox_service.go | 43 + .../internal/service/inbox_service_test.go | 25 + backend/internal/service/profile_service.go | 48 ++ backend/internal/service/rbac_service.go | 27 +- backend/internal/service/team_service.go | 20 +- .../internal/service/whatsapp_call_service.go | 75 ++ ...7_add_chatwoot_4_15_parity_fields.down.sql | 15 + ...057_add_chatwoot_4_15_parity_fields.up.sql | 55 ++ ...58_align_category_slug_uniqueness.down.sql | 5 + ...0058_align_category_slug_uniqueness.up.sql | 5 + .../scripts/parity_frontend_browser_smoke.mjs | 44 +- backend/scripts/parity_frontend_smoke.sh | 101 ++- docs/parity/chatwoot-routes-static.md | 777 +++++++++--------- docs/parity/frontend-smoke-report.md | 6 +- docs/parity/gochat-routes.txt | 48 +- docs/parity/route-parity.md | 18 +- docs/tracking/01-chatwoot-parity-tracker.md | 66 +- 84 files changed, 4013 insertions(+), 575 deletions(-) create mode 100644 backend/cmd/route_parity/main_test.go create mode 100644 backend/internal/auth/refresh_store_test.go create mode 100644 backend/internal/handler/api/v1/chatwoot_415_serializer_test.go create mode 100644 backend/internal/model/captain_message_report.go create mode 100644 backend/internal/model/user_session.go create mode 100644 backend/internal/service/enterprise_billing_worker.go create mode 100644 backend/internal/service/enterprise_billing_worker_test.go create mode 100644 backend/migrations/000057_add_chatwoot_4_15_parity_fields.down.sql create mode 100644 backend/migrations/000057_add_chatwoot_4_15_parity_fields.up.sql create mode 100644 backend/migrations/000058_align_category_slug_uniqueness.down.sql create mode 100644 backend/migrations/000058_align_category_slug_uniqueness.up.sql diff --git a/backend/cmd/gochat/main.go b/backend/cmd/gochat/main.go index a4f2868e..b8ee2d1a 100644 --- a/backend/cmd/gochat/main.go +++ b/backend/cmd/gochat/main.go @@ -112,10 +112,12 @@ func closeDB(db *gorm.DB) { } type smokeSeedSummary struct { + AdminID uint `json:"admin_id"` AdminEmail string `json:"admin_email"` AdminPassword string `json:"admin_password"` AccountID uint `json:"account_id"` InboxID uint `json:"inbox_id"` + VoiceInboxID uint `json:"voice_inbox_id"` ContactID uint `json:"contact_id"` CompanyID uint `json:"company_id"` PortalID uint `json:"portal_id"` @@ -128,6 +130,8 @@ type smokeSeedSummary struct { CustomRoleID uint `json:"custom_role_id"` CapacityPolicyID uint `json:"capacity_policy_id"` CaptainAssistantID uint `json:"captain_assistant_id"` + CaptainMessageID uint `json:"captain_message_id"` + AgentBotID uint `json:"agent_bot_id"` } func seedSmokeData(ctx context.Context, db *gorm.DB) (*smokeSeedSummary, error) { @@ -175,6 +179,13 @@ func seedSmokeData(ctx context.Context, db *gorm.DB) (*smokeSeedSummary, error) if err := db.WithContext(ctx).Where("inbox_id = ? AND user_id = ?", inbox.ID, admin.ID).FirstOrCreate(&model.InboxMember{}, model.InboxMember{InboxID: inbox.ID, UserID: admin.ID, Role: "administrator"}).Error; err != nil { return nil, fmt.Errorf("seed inbox member: %w", err) } + voiceInbox := &model.Inbox{} + if err := db.WithContext(ctx).Where("account_id = ? AND name = ?", account.ID, "Smoke Voice Inbox").FirstOrCreate(voiceInbox, model.Inbox{AccountID: account.ID, Name: "Smoke Voice Inbox", ChannelType: "twilio_sms", ChannelID: 999999, Enabled: true, ChannelConfig: `{"voice_enabled":true,"inbound_calls_enabled":true}`}).Error; err != nil { + return nil, fmt.Errorf("seed voice inbox: %w", err) + } + if err := db.WithContext(ctx).Model(voiceInbox).Update("channel_config", `{"voice_enabled":true,"inbound_calls_enabled":true}`).Error; err != nil { + return nil, fmt.Errorf("update smoke voice inbox: %w", err) + } company := &model.Company{} 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 { @@ -266,12 +277,20 @@ func seedSmokeData(ctx context.Context, db *gorm.DB) (*smokeSeedSummary, error) if err := db.WithContext(ctx).Where("account_id = ? AND name = ?", account.ID, "Smoke Captain").FirstOrCreate(assistant, model.CaptainAssistant{AccountID: account.ID, Name: "Smoke Captain", Description: "B12 smoke assistant", Status: model.AssistantStatusActive, Config: json.RawMessage(`{"model":"gpt-4o"}`)}).Error; err != nil { return nil, fmt.Errorf("seed captain assistant: %w", err) } + captainMessage := &model.Message{} + if err := db.WithContext(ctx).Where("conversation_id = ? AND content = ?", conversation.ID, "Smoke Captain answer").FirstOrCreate(captainMessage, model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, SenderID: &assistant.ID, SenderType: "Captain::Assistant", MessageType: "outgoing", ContentType: "text", Content: "Smoke Captain answer", Status: "sent"}).Error; err != nil { + return nil, fmt.Errorf("seed captain message: %w", err) + } + agentBot := &model.AgentBot{} + if err := db.WithContext(ctx).Where("name = ? AND account_id = ?", "Smoke Agent Bot", account.ID).FirstOrCreate(agentBot, model.AgentBot{AccountID: &account.ID, Name: "Smoke Agent Bot", Description: "B12 assignment smoke bot", BotType: "webhook", AccessToken: fmt.Sprintf("smoke-agent-bot-%d", account.ID), Secret: fmt.Sprintf("smoke-agent-bot-secret-%d", account.ID)}).Error; err != nil { + return nil, fmt.Errorf("seed agent bot: %w", err) + } conversationDisplayID := uint(0) 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, 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 + return &smokeSeedSummary{AdminID: admin.ID, AdminEmail: adminEmail, AdminPassword: adminPassword, AccountID: account.ID, InboxID: inbox.ID, VoiceInboxID: voiceInbox.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, CaptainMessageID: captainMessage.ID, AgentBotID: agentBot.ID}, nil } func seedMessage(ctx context.Context, db *gorm.DB, conversation *model.Conversation, inboxID, senderID uint, messageType, contentType, content string) (*model.Message, error) { diff --git a/backend/cmd/reindex_search/main.go b/backend/cmd/reindex_search/main.go index 6b5c0c90..d0ce7b40 100644 --- a/backend/cmd/reindex_search/main.go +++ b/backend/cmd/reindex_search/main.go @@ -32,7 +32,12 @@ func main() { os.Exit(1) } - cfg, err := config.Load() + config.LoadDotEnvEnvironment() + env := os.Getenv("GOCHAT_ENV") + if env == "" { + env = "development" + } + cfg, err := config.LoadWithEnv(env) if err != nil { fmt.Fprintf(os.Stderr, "config load: %v\n", err) os.Exit(1) diff --git a/backend/cmd/route_parity/main.go b/backend/cmd/route_parity/main.go index ecfd671b..ed2d9948 100644 --- a/backend/cmd/route_parity/main.go +++ b/backend/cmd/route_parity/main.go @@ -494,11 +494,26 @@ var criticalRoutes = []route{ {Method: "GET", Path: "/api/v2/accounts/:account_id/year_in_review", Controller: "api/v2/accounts/year_in_reviews#show", Source: "routes.rb:505"}, {Method: "GET", Path: "/api/v2/accounts/:account_id/live_reports/conversation_metrics", Controller: "api/v2/accounts/live_reports#conversation_metrics", Source: "routes.rb:508"}, {Method: "GET", Path: "/api/v2/accounts/:account_id/live_reports/grouped_conversation_metrics", Controller: "api/v2/accounts/live_reports#grouped_conversation_metrics", Source: "routes.rb:509"}, + + // Chatwoot 4.15.1 frontend additions. Keep these explicit so a route dump + // regression fails parity instead of being hidden by the legacy 4.14 list. + {Method: "GET", Path: "/api/v1/accounts/:account_id/onboarding/help_center_generation", Controller: "api/v1/accounts/onboardings#help_center_generation", Source: "routes.rb:59"}, + {Method: "GET", Path: "/api/v1/accounts/:account_id/captain/assistants/:assistant_id/stats", Controller: "api/v1/accounts/captain/assistants#stats", Source: "routes.rb:69"}, + {Method: "GET", Path: "/api/v1/accounts/:account_id/captain/assistants/:assistant_id/summary", Controller: "api/v1/accounts/captain/assistants#summary", Source: "routes.rb:70"}, + {Method: "GET", Path: "/api/v1/accounts/:account_id/captain/assistants/:assistant_id/drilldown", Controller: "api/v1/accounts/captain/assistants#drilldown", Source: "routes.rb:71"}, + {Method: "POST", Path: "/api/v1/accounts/:account_id/captain/message_reports", Controller: "api/v1/accounts/captain/message_reports#create", Source: "routes.rb:80"}, + {Method: "GET", Path: "/api/v1/accounts/:account_id/calls", Controller: "api/v1/accounts/calls#index", Source: "routes.rb:243"}, + {Method: "POST", Path: "/api/v1/accounts/:account_id/inboxes/:inbox_id/set_inbound_calls", Controller: "api/v1/accounts/inboxes#set_inbound_calls", Source: "routes.rb:275"}, + {Method: "GET", Path: "/api/v1/profile/sessions", Controller: "api/v1/profile/sessions#index", Source: "routes.rb:445"}, + {Method: "DELETE", Path: "/api/v1/profile/sessions/:id", Controller: "api/v1/profile/sessions#destroy", Source: "routes.rb:445"}, + {Method: "GET", Path: "/api/v2/accounts/:account_id/reports/drilldown", Controller: "api/v2/accounts/reports#drilldown", Source: "routes.rb:508"}, + {Method: "POST", Path: "/enterprise/api/v1/accounts/:account_id/select_billing_currency", Controller: "enterprise/api/v1/accounts#select_billing_currency", Source: "routes.rb:535"}, + {Method: "GET", Path: "/enterprise/api/v1/accounts/:account_id/topup_options", Controller: "enterprise/api/v1/accounts#topup_options", Source: "routes.rb:539"}, } func main() { gochatPath := flag.String("gochat", "docs/parity/gochat-routes.txt", "GoChat route dump") - chatwootPath := flag.String("chatwoot", "reference/chatwoot/config/routes.rb", "Chatwoot routes.rb path") + chatwootPath := flag.String("chatwoot", "docs/chatwoot/config/routes.rb", "Chatwoot routes.rb path") outPath := flag.String("out", "docs/parity/route-parity.md", "route parity markdown output") chatwootOut := flag.String("chatwoot-out", "docs/parity/chatwoot-routes-static.md", "Chatwoot static route source output") flag.Parse() @@ -601,7 +616,7 @@ func writeParity(path string, gochatPath string, chatwootPath string, gochatRout b.WriteString("Generated from:\n\n") b.WriteString("- GoChat route dump: `" + gochatPath + "`\n") b.WriteString("- Chatwoot route source: `" + chatwootPath + "`\n\n") - b.WriteString("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`.\n\n") + b.WriteString("This report covers tracked frontend-critical Chatwoot 4.15.1 routes from `docs/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`.\n\n") b.WriteString(fmt.Sprintf("Summary: %d exact, %d method-compatible, %d parameter-compatible, %d missing out of %d tracked critical routes.\n\n", len(exact), len(methodCompatible), len(compatible), len(missing), len(criticalRoutes))) b.WriteString("## Missing Critical Routes\n\n") diff --git a/backend/cmd/route_parity/main_test.go b/backend/cmd/route_parity/main_test.go new file mode 100644 index 00000000..b309af80 --- /dev/null +++ b/backend/cmd/route_parity/main_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestChatwoot415RoutesAreTrackedAndReportedMissing(t *testing.T) { + expected := []string{ + "GET /api/v1/accounts/:account_id/onboarding/help_center_generation", + "GET /api/v1/accounts/:account_id/captain/assistants/:assistant_id/stats", + "GET /api/v1/accounts/:account_id/captain/assistants/:assistant_id/summary", + "GET /api/v1/accounts/:account_id/captain/assistants/:assistant_id/drilldown", + "POST /api/v1/accounts/:account_id/captain/message_reports", + "GET /api/v1/accounts/:account_id/calls", + "POST /api/v1/accounts/:account_id/inboxes/:inbox_id/set_inbound_calls", + "GET /api/v1/profile/sessions", + "DELETE /api/v1/profile/sessions/:id", + "GET /api/v2/accounts/:account_id/reports/drilldown", + "POST /enterprise/api/v1/accounts/:account_id/select_billing_currency", + "GET /enterprise/api/v1/accounts/:account_id/topup_options", + } + tracked := make(map[string]bool, len(criticalRoutes)) + for _, item := range criticalRoutes { + tracked[item.Method+" "+item.Path] = true + } + for _, item := range expected { + if !tracked[item] { + t.Fatalf("Chatwoot 4.15.1 route is not tracked: %s", item) + } + } + + routes := make(map[string]route, len(criticalRoutes)) + for _, item := range criticalRoutes { + routes[key(item.Method, item.Path)] = item + } + delete(routes, key("GET", "/api/v2/accounts/:account_id/reports/drilldown")) + out := filepath.Join(t.TempDir(), "parity.md") + if err := writeParity(out, "gochat-routes.txt", "docs/chatwoot/config/routes.rb", routes); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + report := string(data) + if !strings.Contains(report, "`/api/v2/accounts/:account_id/reports/drilldown`") || !strings.Contains(report, "| missing |") { + t.Fatalf("removed 4.15.1 route was not reported missing:\n%s", report) + } +} diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 42e62d0d..2372aee9 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -255,6 +255,8 @@ func autoMigrate(db *gorm.DB) error { &model.MessageReaction{}, &model.Report{}, &model.BackgroundJob{}, + &model.UserSession{}, + &model.CaptainMessageReport{}, // S6: WorkingHour — out-of-office / business hours per inbox &model.WorkingHour{}, // Notification settings — per-user per-account notification preferences diff --git a/backend/internal/app/bootstrap.go b/backend/internal/app/bootstrap.go index 812668e1..fb54e6b7 100644 --- a/backend/internal/app/bootstrap.go +++ b/backend/internal/app/bootstrap.go @@ -209,6 +209,7 @@ func Bootstrap(env string) (*App, error) { webhookSubRepo := repository.NewWebhookSubscriptionRepo(db) conversationParticipantRepo := repository.NewConversationParticipantRepo(db) draftMessageRepo := repository.NewDraftMessageRepo(db) + whatsAppCallRepo := repository.NewWhatsAppCallRepo(db) // M11: Pre-chat form repo (needed by WidgetService) preChatFormRepo := repository.NewPreChatFormRepo(db) @@ -321,6 +322,8 @@ func Bootstrap(env string) (*App, error) { // Step 8: Wire services (business logic layer) authService := service.NewAuthService(db, jwtService, refreshStore, oauthService, mfaService) accountService := service.NewAccountService(accountRepo) + accountService.SetWorkerPool(workerPool) + whatsAppCallService := service.NewWhatsAppCallService(whatsAppCallRepo) contactInboxService := service.NewContactInboxService(contactInboxRepo) noteRepo := repository.NewNoteRepo(db) contactNoteService := service.NewContactNoteService(contactRepo, contactNoteRepo) @@ -619,7 +622,7 @@ func Bootstrap(env string) (*App, error) { } // Captain services (P10 M10 — Captain AI + Copilot) - captainAssistantService := service.NewCaptainAssistantService(captainAssistantRepo, captainInboxRepo, captainDocumentRepo, captainAssistantResponseRepo, llmProvider) + captainAssistantService := service.NewCaptainAssistantService(captainAssistantRepo, captainInboxRepo, captainDocumentRepo, captainAssistantResponseRepo, llmProvider, rdb) captainDocumentService := service.NewCaptainDocumentService(captainDocumentRepo, llmProvider, captainAssistantRepo) captainDocumentService.SetResponseRepo(captainAssistantResponseRepo) captainDocumentService.SetWorkerPool(workerPool) @@ -703,7 +706,7 @@ func Bootstrap(env string) (*App, error) { // Team + Profile services (P5 — Teams + Team Members + User Profiles) teamService := service.NewTeamService(teamRepo, teamMemberRepo, db) agentService := service.NewAgentService(agentRepo, db) - profileService := service.NewProfileService(userRepo, accountUserRepo, accessTokenRepo, installationConfigRepo) + profileService := service.NewProfileService(userRepo, accountUserRepo, accessTokenRepo, installationConfigRepo, refreshStore) profileService.SetConfirmationMailer(service.NewEnvProfileConfirmationMailer()) // Campaign + AutoAssignment services @@ -814,6 +817,7 @@ func Bootstrap(env string) (*App, error) { contactMergeRepo := repository.NewContactMergeRepo(db) contactMergeService := service.NewContactMergeService(contactMergeRepo, db) handlers := &router.Handlers{ + RBAC: service.NewRBACService(db), Auth: v1.NewAuthHandler(authService, oauthService, profileService), MFA: v1.NewMFAHandler(mfaService), SAML: v1.NewSAMLHandler(samlService, jwtService, refreshStore, ssoSessionStore, &cfg.SAML), @@ -878,6 +882,7 @@ func Bootstrap(env string) (*App, error) { TelegramWebhook: telegramWebhookHandler, FacebookWebhook: facebookWebhookHandler, WhatsAppWebhook: whatsappWebhookHandler, + WhatsAppCall: v1.NewWhatsAppCallHandler(whatsAppCallService), TikTokWebhook: tiktokWebhookHandler, LineWebhook: lineWebhookHandler, TwilioWebhook: twilioWebhookHandler, diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go index 79491c36..11546296 100644 --- a/backend/internal/auth/jwt.go +++ b/backend/internal/auth/jwt.go @@ -23,6 +23,7 @@ type Claims struct { UserType string `json:"user_type,omitempty"` // user/super_admin platform identity Provider string `json:"provider"` // email/google/saml CustomRoleID uint `json:"custom_role_id,omitempty"` // enterprise custom role + ClientID string `json:"client_id,omitempty"` // DeviseTokenAuth-compatible session id jwt.RegisteredClaims } @@ -47,6 +48,10 @@ func NewJWTService(cfg *config.JWTConfig) *JWTService { // Access Token: 15min expiry with full Claims // Refresh Token: 7 days expiry, only UserID + Provider func (s *JWTService) GenerateTokenPair(user *model.User, accountID uint, role string) (*TokenPair, error) { + return s.GenerateTokenPairForClient(user, accountID, role, "") +} + +func (s *JWTService) GenerateTokenPairForClient(user *model.User, accountID uint, role, clientID string) (*TokenPair, error) { userType := "user" typeValue := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(user.Type), "_", "")) if user.Role == "super_admin" || role == "super_admin" || typeValue == "superadmin" { @@ -67,6 +72,7 @@ func (s *JWTService) GenerateTokenPair(user *model.User, accountID uint, role st } return 0 }(), + ClientID: clientID, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(accessExpiry), IssuedAt: jwt.NewNumericDate(time.Now()), @@ -86,6 +92,7 @@ func (s *JWTService) GenerateTokenPair(user *model.User, accountID uint, role st refreshClaims := &Claims{ UserID: user.ID, Provider: user.Provider, + ClientID: clientID, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(refreshExpiry), IssuedAt: jwt.NewNumericDate(time.Now()), @@ -174,5 +181,5 @@ func (s *JWTService) RefreshAccessToken(refreshTokenString string, accountID uin Provider: claims.Provider, } - return s.GenerateTokenPair(user, accountID, role) + return s.GenerateTokenPairForClient(user, accountID, role, claims.ClientID) } diff --git a/backend/internal/auth/refresh_store.go b/backend/internal/auth/refresh_store.go index 498f7903..77c628b6 100644 --- a/backend/internal/auth/refresh_store.go +++ b/backend/internal/auth/refresh_store.go @@ -20,37 +20,54 @@ type RefreshTokenStore struct { rdb *redis.Client cfg *config.JWTConfig mu sync.RWMutex - mem map[uint]string + mem map[string]refreshTokenEntry +} + +type refreshTokenEntry struct { + token string + expiresAt time.Time } // NewRefreshTokenStore creates a refresh token store backed by Redis. func NewRefreshTokenStore(rdb *redis.Client, cfg *config.JWTConfig) *RefreshTokenStore { - return &RefreshTokenStore{rdb: rdb, cfg: cfg, mem: map[uint]string{}} + return &RefreshTokenStore{rdb: rdb, cfg: cfg, mem: map[string]refreshTokenEntry{}} } // Store saves a refresh token in Redis with TTL. // Key pattern: refresh_token:{user_id}:{token_hash} func (s *RefreshTokenStore) Store(ctx context.Context, userID uint, refreshToken string) error { + return s.StoreForClient(ctx, userID, "", refreshToken) +} + +func (s *RefreshTokenStore) StoreForClient(ctx context.Context, userID uint, clientID, refreshToken string) error { + key := s.key(userID, clientID) + ttl := time.Duration(s.cfg.RefreshExpiryHours) * time.Hour if s.rdb == nil { s.mu.Lock() defer s.mu.Unlock() - s.mem[userID] = refreshToken + s.mem[key] = refreshTokenEntry{token: refreshToken, expiresAt: time.Now().Add(ttl)} return nil } - key := fmt.Sprintf("gochat:refresh_token:%d", userID) - ttl := time.Duration(s.cfg.RefreshExpiryHours) * time.Hour return s.rdb.Set(ctx, key, refreshToken, ttl).Err() } // Validate checks if a refresh token exists and matches the stored value. func (s *RefreshTokenStore) Validate(ctx context.Context, userID uint, refreshToken string) (bool, error) { + return s.ValidateForClient(ctx, userID, "", refreshToken) +} + +func (s *RefreshTokenStore) ValidateForClient(ctx context.Context, userID uint, clientID, refreshToken string) (bool, error) { + key := s.key(userID, clientID) if s.rdb == nil { - s.mu.RLock() - defer s.mu.RUnlock() - stored, ok := s.mem[userID] - return ok && stored == refreshToken, nil + s.mu.Lock() + defer s.mu.Unlock() + stored, ok := s.mem[key] + if ok && time.Now().After(stored.expiresAt) { + delete(s.mem, key) + return false, nil + } + return ok && stored.token == refreshToken, nil } - key := fmt.Sprintf("gochat:refresh_token:%d", userID) stored, err := s.rdb.Get(ctx, key).Result() if err == redis.Nil { return false, nil // token not found (expired or revoked) @@ -63,13 +80,17 @@ func (s *RefreshTokenStore) Validate(ctx context.Context, userID uint, refreshTo // Revoke removes a refresh token from Redis (logout). func (s *RefreshTokenStore) Revoke(ctx context.Context, userID uint) error { + return s.RevokeClient(ctx, userID, "") +} + +func (s *RefreshTokenStore) RevokeClient(ctx context.Context, userID uint, clientID string) error { + key := s.key(userID, clientID) if s.rdb == nil { s.mu.Lock() defer s.mu.Unlock() - delete(s.mem, userID) + delete(s.mem, key) return nil } - key := fmt.Sprintf("gochat:refresh_token:%d", userID) return s.rdb.Del(ctx, key).Err() } @@ -78,3 +99,30 @@ func (s *RefreshTokenStore) Revoke(ctx context.Context, userID uint) error { func (s *RefreshTokenStore) Rotate(ctx context.Context, userID uint, newRefreshToken string) error { return s.Store(ctx, userID, newRefreshToken) } + +func (s *RefreshTokenStore) RotateForClient(ctx context.Context, userID uint, clientID, newRefreshToken string) error { + return s.StoreForClient(ctx, userID, clientID, newRefreshToken) +} + +func (s *RefreshTokenStore) HasClient(ctx context.Context, userID uint, clientID string) (bool, error) { + key := s.key(userID, clientID) + if s.rdb == nil { + s.mu.Lock() + defer s.mu.Unlock() + entry, ok := s.mem[key] + if ok && time.Now().After(entry.expiresAt) { + delete(s.mem, key) + return false, nil + } + return ok, nil + } + n, err := s.rdb.Exists(ctx, key).Result() + return n > 0, err +} + +func (s *RefreshTokenStore) key(userID uint, clientID string) string { + if clientID == "" { + return fmt.Sprintf("gochat:refresh_token:%d", userID) + } + return fmt.Sprintf("gochat:refresh_token:%d:%s", userID, clientID) +} diff --git a/backend/internal/auth/refresh_store_test.go b/backend/internal/auth/refresh_store_test.go new file mode 100644 index 00000000..c65f6054 --- /dev/null +++ b/backend/internal/auth/refresh_store_test.go @@ -0,0 +1,27 @@ +package auth + +import ( + "context" + "testing" + + "github.com/gochat/gochat/internal/config" + "github.com/stretchr/testify/require" +) + +func TestRefreshTokenStoreScopesTokensByClient(t *testing.T) { + store := NewRefreshTokenStore(nil, &config.JWTConfig{RefreshExpiryHours: 24}) + ctx := context.Background() + require.NoError(t, store.StoreForClient(ctx, 7, "chrome", "chrome-token")) + require.NoError(t, store.StoreForClient(ctx, 7, "firefox", "firefox-token")) + + valid, err := store.ValidateForClient(ctx, 7, "chrome", "chrome-token") + require.NoError(t, err) + require.True(t, valid) + require.NoError(t, store.RevokeClient(ctx, 7, "chrome")) + valid, err = store.ValidateForClient(ctx, 7, "chrome", "chrome-token") + require.NoError(t, err) + require.False(t, valid) + valid, err = store.ValidateForClient(ctx, 7, "firefox", "firefox-token") + require.NoError(t, err) + require.True(t, valid) +} diff --git a/backend/internal/handler/api/v1/account_handler.go b/backend/internal/handler/api/v1/account_handler.go index 705ab673..7dbbd57e 100644 --- a/backend/internal/handler/api/v1/account_handler.go +++ b/backend/internal/handler/api/v1/account_handler.go @@ -180,6 +180,21 @@ func (h *AccountHandler) UpdateOnboarding(c *gin.Context) { c.JSON(http.StatusOK, serializeAccount(account)) } +// HelpCenterGeneration returns the Chatwoot 4.15 onboarding generation status. +func (h *AccountHandler) HelpCenterGeneration(c *gin.Context) { + id := parseAccountIDParam(c) + if id == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") + return + } + payload, err := h.svc.HelpCenterGenerationStatus(c.Request.Context(), id) + if err != nil { + handleServiceError(c, err) + return + } + c.JSON(http.StatusOK, payload) +} + // @Summary Delete an account // @Description Soft-deletes an account by ID // @Tags Accounts @@ -419,6 +434,8 @@ func serializeAccountCustomAttributes(account *model.Account) map[string]any { copyPresentAttribute(out, attrs, "logo") copyPresentAttribute(out, attrs, "referral_source") copyPresentAttribute(out, attrs, "brand_info") + copyPresentAttribute(out, attrs, "billing_currency") + copyPresentAttribute(out, attrs, "help_center_generation_id") if account.OnboardingStep != "" { out["onboarding_step"] = account.OnboardingStep } else if isPresent(attrs["onboarding_step"]) { diff --git a/backend/internal/handler/api/v1/account_handler_test.go b/backend/internal/handler/api/v1/account_handler_test.go index 3158526d..d8955d8f 100644 --- a/backend/internal/handler/api/v1/account_handler_test.go +++ b/backend/internal/handler/api/v1/account_handler_test.go @@ -47,6 +47,9 @@ func (s *AccountHandlerTestSuite) SetupSuite() { &model.Account{}, &model.User{}, &model.AccountUser{}, + &model.Portal{}, + &model.Category{}, + &model.Article{}, ) s.Require().NoError(err) @@ -85,6 +88,7 @@ func (s *AccountHandlerTestSuite) SetupSuite() { accountsGroup.PATCH("/:account_id", s.handler.Update) accountsGroup.PUT("/:account_id", s.handler.Update) accountsGroup.PATCH("/:account_id/onboarding", s.handler.UpdateOnboarding) + accountsGroup.GET("/:account_id/onboarding/help_center_generation", s.handler.HelpCenterGeneration) accountsGroup.DELETE("/:account_id", s.handler.Delete) accountsGroup.PUT("/:account_id/settings", s.handler.UpdateSettings) accountsGroup.GET("/:account_id/agents", s.handler.GetAgents) @@ -465,6 +469,24 @@ func (s *AccountHandlerTestSuite) TestUpdateOnboarding_PreservesNonFinalizingAtt assert.Equal(s.T(), "healthcare", storedAttrs["industry"]) } +func (s *AccountHandlerTestSuite) TestHelpCenterGeneration_ExactRawPayload() { + account := s.seedAccount("Help Center") + require.NoError(s.T(), account.SetCustomAttributesMap(map[string]any{"help_center_generation_id": "generation-1", "help_center_generation_state": "completed"})) + require.NoError(s.T(), s.db.Save(account).Error) + portal := &model.Portal{AccountID: account.ID, Name: "Help", Slug: "help", Locale: "en"} + require.NoError(s.T(), s.db.Create(portal).Error) + category := &model.Category{AccountID: account.ID, PortalID: portal.ID, Name: "FAQ", Slug: "faq", Locale: "en"} + require.NoError(s.T(), s.db.Create(category).Error) + article := &model.Article{AccountID: account.ID, PortalID: portal.ID, CategoryID: &category.ID, Title: "Answer", Slug: "answer", Locale: "en"} + require.NoError(s.T(), s.db.Create(article).Error) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/onboarding/help_center_generation", account.ID), nil) + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code, w.Body.String()) + payload := s.unmarshalResponse(w) + s.Equal(map[string]any{"generation_id": "generation-1", "state": "completed", "articles_count": float64(1), "categories_count": float64(1)}, payload) +} + // ====== Delete Account ====== func (s *AccountHandlerTestSuite) TestDelete_Success() { diff --git a/backend/internal/handler/api/v1/analytics_handler.go b/backend/internal/handler/api/v1/analytics_handler.go index 3ea4c5c0..c58c000a 100644 --- a/backend/internal/handler/api/v1/analytics_handler.go +++ b/backend/internal/handler/api/v1/analytics_handler.go @@ -63,6 +63,64 @@ func (h *AnalyticsHandler) Index(c *gin.Context) { c.JSON(http.StatusOK, result) } +// Drilldown lists the raw records behind one report chart bucket. +// GET /api/v2/accounts/:account_id/reports/drilldown +func (h *AnalyticsHandler) Drilldown(c *gin.Context) { + accountID, ok := parseAccountID(c) + if !ok { + return + } + metric := c.Query("metric") + bucketRaw := c.Query("bucket_timestamp") + if metric == "" || bucketRaw == "" || c.Query("since") == "" || c.Query("until") == "" { + c.Status(http.StatusUnprocessableEntity) + return + } + since, until, ok := parseDateRange(c) + if !ok { + return + } + bucket, err := parseChatwootReportTime(bucketRaw) + if err != nil { + c.Status(http.StatusUnprocessableEntity) + return + } + dimensionType := c.DefaultQuery("type", "account") + if dimensionType != "account" && dimensionType != "inbox" && dimensionType != "agent" && dimensionType != "label" && dimensionType != "team" { + c.Status(http.StatusUnprocessableEntity) + return + } + id, ok := parseOptionalReportID(c) + if !ok { + return + } + if dimensionType != "account" && id == 0 { + c.Status(http.StatusUnprocessableEntity) + return + } + groupBy := c.DefaultQuery("group_by", "day") + if groupBy != "hour" && groupBy != "day" && groupBy != "week" && groupBy != "month" && groupBy != "year" { + c.Status(http.StatusUnprocessableEntity) + return + } + offset, ok := parseReportTimezoneOffset(c) + if !ok { + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "25")) + result, err := h.svc.GetDrilldown(c.Request.Context(), accountID, service.ReportDrilldownParams{Metric: metric, DimensionType: dimensionType, DimensionID: id, GroupBy: groupBy, Since: since, Until: until, BucketTimestamp: bucket, TimezoneOffset: offset, BusinessHours: parseReportBusinessHours(c), Page: page, PerPage: perPage}) + if err != nil { + if strings.Contains(err.Error(), "unsupported") || strings.Contains(err.Error(), "invalid bucket") { + c.Status(http.StatusUnprocessableEntity) + return + } + handleServiceError(c, err) + return + } + c.JSON(http.StatusOK, result) +} + // parseAccountID extracts account_id from URL params. func parseAccountID(c *gin.Context) (uint, bool) { id := getAccountID(c) diff --git a/backend/internal/handler/api/v1/analytics_handler_test.go b/backend/internal/handler/api/v1/analytics_handler_test.go index 983ff23c..4c81de5c 100644 --- a/backend/internal/handler/api/v1/analytics_handler_test.go +++ b/backend/internal/handler/api/v1/analytics_handler_test.go @@ -43,6 +43,7 @@ func (s *AnalyticsHandlerTestSuite) SetupSuite() { &model.User{}, &model.AccountUser{}, &model.Inbox{}, + &model.Contact{}, &model.Team{}, &model.Tag{}, &model.Conversation{}, @@ -80,6 +81,7 @@ func (s *AnalyticsHandlerTestSuite) SetupSuite() { v2Accounts := r.Group("/api/v2/accounts/:account_id") v2Reports := v2Accounts.Group("/reports") v2Reports.GET("", s.handler.Index) + v2Reports.GET("/drilldown", s.handler.Drilldown) v2Reports.GET("/summary", s.handler.Summary) v2Reports.GET("/agents", s.handler.AgentMetrics) v2Reports.GET("/inboxes", s.handler.InboxMetrics) @@ -110,10 +112,170 @@ func (s *AnalyticsHandlerTestSuite) SetupTest() { s.db.Exec("DELETE FROM tags") s.db.Exec("DELETE FROM teams") s.db.Exec("DELETE FROM inboxes") + s.db.Exec("DELETE FROM contacts") s.db.Exec("DELETE FROM account_users") s.db.Exec("DELETE FROM users") } +func (s *AnalyticsHandlerTestSuite) TestDrilldown_ReturnsChatwootMessageRecordEnvelope() { + now := time.Now().UTC().Truncate(time.Second) + inbox := &model.Inbox{AccountID: s.accountID, Name: "Support", ChannelType: "api", ChannelID: 1} + s.Require().NoError(s.db.Create(inbox).Error) + contact := &model.Contact{AccountID: s.accountID, Name: "Ada"} + s.Require().NoError(s.db.Create(contact).Error) + displayID := uint(10) + conversation := &model.Conversation{Base: model.Base{CreatedAt: now.Add(-2 * time.Hour)}, AccountID: s.accountID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open", ChannelType: "api", Channel: "api"} + s.Require().NoError(s.db.Create(conversation).Error) + message := &model.Message{Base: model.Base{CreatedAt: now.Add(-30 * time.Minute)}, AccountID: s.accountID, InboxID: inbox.ID, ConversationID: conversation.ID, Content: "Hello", ContentType: "text", MessageType: "incoming"} + s.Require().NoError(s.db.Create(message).Error) + since := now.Add(-24 * time.Hour).Unix() + until := now.Add(24 * time.Hour).Unix() + bucket := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Unix() + path := "/api/v2/accounts/" + strconv.FormatUint(uint64(s.accountID), 10) + "/reports/drilldown?metric=incoming_messages_count&type=account&group_by=day&since=" + strconv.FormatInt(since, 10) + "&until=" + strconv.FormatInt(until, 10) + "&bucket_timestamp=" + strconv.FormatInt(bucket, 10) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code, w.Body.String()) + result := assertChatwootJSONObject(s.T(), w.Body.String(), []string{"meta", "payload"}) + meta := result["meta"].(map[string]any) + s.Equal("message", meta["record_type"]) + s.Equal(float64(1), meta["total_count"]) + payload := result["payload"].([]any) + s.Require().Len(payload, 1) + record := payload[0].(map[string]any) + for _, key := range []string{"record_type", "conversation", "message", "metric_value", "occurred_at"} { + s.Contains(record, key) + } + s.Equal("Hello", record["message"].(map[string]any)["content"]) + + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, strings.Replace(path, "incoming_messages_count", "unknown_metric", 1), nil) + s.router.ServeHTTP(w, req) + s.Equal(http.StatusUnprocessableEntity, w.Code) +} + +func (s *AnalyticsHandlerTestSuite) TestDrilldown_PaginatesAndScopesAccount() { + now := time.Now().UTC().Truncate(time.Second) + inbox := &model.Inbox{AccountID: s.accountID, Name: "Support", ChannelType: "api", ChannelID: 2} + s.Require().NoError(s.db.Create(inbox).Error) + contact := &model.Contact{AccountID: s.accountID, Name: "Ada"} + s.Require().NoError(s.db.Create(contact).Error) + conversation := &model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", ChannelType: "api", Channel: "api"} + s.Require().NoError(s.db.Create(conversation).Error) + for _, content := range []string{"first", "second"} { + s.Require().NoError(s.db.Create(&model.Message{Base: model.Base{CreatedAt: now.Add(-time.Hour)}, AccountID: s.accountID, InboxID: inbox.ID, ConversationID: conversation.ID, Content: content, ContentType: "text", MessageType: "incoming"}).Error) + } + other := &model.Account{Name: "Other"} + s.Require().NoError(s.db.Create(other).Error) + otherInbox := &model.Inbox{AccountID: other.ID, Name: "Other", ChannelType: "api", ChannelID: 3} + s.Require().NoError(s.db.Create(otherInbox).Error) + otherContact := &model.Contact{AccountID: other.ID, Name: "Other"} + s.Require().NoError(s.db.Create(otherContact).Error) + otherConversation := &model.Conversation{AccountID: other.ID, InboxID: otherInbox.ID, ContactID: otherContact.ID, Status: "open", ChannelType: "api", Channel: "api"} + s.Require().NoError(s.db.Create(otherConversation).Error) + s.Require().NoError(s.db.Create(&model.Message{Base: model.Base{CreatedAt: now.Add(-time.Hour)}, AccountID: other.ID, InboxID: otherInbox.ID, ConversationID: otherConversation.ID, Content: "foreign", MessageType: "incoming"}).Error) + bucket := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Unix() + path := "/api/v2/accounts/" + strconv.FormatUint(uint64(s.accountID), 10) + "/reports/drilldown?metric=incoming_messages_count&type=account&group_by=day&timezone_offset=0&page=2&per_page=1&since=" + strconv.FormatInt(now.Add(-24*time.Hour).Unix(), 10) + "&until=" + strconv.FormatInt(now.Add(24*time.Hour).Unix(), 10) + "&bucket_timestamp=" + strconv.FormatInt(bucket, 10) + w := httptest.NewRecorder() + s.router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + s.Equal(http.StatusOK, w.Code, w.Body.String()) + result := assertChatwootJSONObject(s.T(), w.Body.String(), []string{"meta", "payload"}) + meta := result["meta"].(map[string]any) + s.Equal(float64(2), meta["total_count"]) + s.Equal(float64(1), meta["conversation_count"]) + s.Equal(float64(2), meta["current_page"]) + s.Len(result["payload"], 1) +} + +func (s *AnalyticsHandlerTestSuite) TestDrilldown_FirstResponseInfersMessageAndMetricValue() { + now := time.Now().UTC().Truncate(time.Second) + inbox := &model.Inbox{AccountID: s.accountID, Name: "Support", ChannelType: "api", ChannelID: 4} + s.Require().NoError(s.db.Create(inbox).Error) + contact := &model.Contact{AccountID: s.accountID, Name: "Ada"} + s.Require().NoError(s.db.Create(contact).Error) + conversation := &model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", ChannelType: "api", Channel: "api"} + s.Require().NoError(s.db.Create(conversation).Error) + message := &model.Message{Base: model.Base{CreatedAt: now}, AccountID: s.accountID, InboxID: inbox.ID, ConversationID: conversation.ID, Content: "Reply", MessageType: "outgoing"} + s.Require().NoError(s.db.Create(message).Error) + event := &model.ReportingEvent{Base: model.Base{CreatedAt: now}, AccountID: s.accountID, Name: "first_response", Value: 12, ValueInBusinessHours: 9, ConversationID: &conversation.ID, InboxID: &inbox.ID, EventStartTime: now.Add(-12 * time.Second), EventEndTime: now} + s.Require().NoError(s.db.Create(event).Error) + bucket := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Unix() + path := "/api/v2/accounts/" + strconv.FormatUint(uint64(s.accountID), 10) + "/reports/drilldown?metric=avg_first_response_time&type=account&group_by=day&business_hours=true&since=" + strconv.FormatInt(now.Add(-24*time.Hour).Unix(), 10) + "&until=" + strconv.FormatInt(now.Add(24*time.Hour).Unix(), 10) + "&bucket_timestamp=" + strconv.FormatInt(bucket, 10) + w := httptest.NewRecorder() + s.router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + s.Equal(http.StatusOK, w.Code, w.Body.String()) + result := assertChatwootJSONObject(s.T(), w.Body.String(), []string{"meta", "payload"}) + record := result["payload"].([]any)[0].(map[string]any) + s.Equal("message", record["record_type"]) + s.Equal(float64(9), record["metric_value"]) + s.Equal("Reply", record["message"].(map[string]any)["content"]) +} + +func (s *AnalyticsHandlerTestSuite) TestDrilldown_DimensionsCountStrategiesAndTimezoneBucketsMatchChatwoot() { + now := time.Now().UTC().Truncate(time.Second) + inbox := &model.Inbox{AccountID: s.accountID, Name: "Support", ChannelType: "api", ChannelID: 9} + team := &model.Team{AccountID: s.accountID, Name: "Escalations"} + label := &model.Tag{AccountID: s.accountID, Name: "billing"} + contact := &model.Contact{AccountID: s.accountID, Name: "Ada"} + s.Require().NoError(s.db.Create(inbox).Error) + s.Require().NoError(s.db.Create(team).Error) + s.Require().NoError(s.db.Create(label).Error) + s.Require().NoError(s.db.Create(contact).Error) + conversations := make([]model.Conversation, 2) + for i := range conversations { + conversations[i] = model.Conversation{Base: model.Base{CreatedAt: now.Add(-2 * time.Hour)}, AccountID: s.accountID, InboxID: inbox.ID, ContactID: contact.ID, TeamID: &team.ID, Status: "open", ChannelType: "api", Channel: "api"} + s.Require().NoError(s.db.Create(&conversations[i]).Error) + s.Require().NoError(s.db.Create(&model.ConversationLabel{AccountID: s.accountID, ConversationID: conversations[i].ID, TagID: label.ID}).Error) + s.Require().NoError(s.db.Create(&model.Message{Base: model.Base{CreatedAt: now.Add(-time.Hour)}, AccountID: s.accountID, InboxID: inbox.ID, ConversationID: conversations[i].ID, MessageType: "incoming", Content: "Hello"}).Error) + } + seedEvent := func(conversationID uint, name string) { + s.Require().NoError(s.db.Create(&model.ReportingEvent{Base: model.Base{CreatedAt: now.Add(-time.Hour)}, AccountID: s.accountID, ConversationID: &conversationID, InboxID: &inbox.ID, Name: name, EventEndTime: now.Add(-time.Hour)}).Error) + } + seedEvent(conversations[0].ID, "conversation_bot_resolved") + seedEvent(conversations[0].ID, "conversation_bot_handoff") + seedEvent(conversations[0].ID, "conversation_bot_handoff") + seedEvent(conversations[1].ID, "conversation_bot_resolved") + base := "since=" + strconv.FormatInt(now.Add(-24*time.Hour).Unix(), 10) + "&until=" + strconv.FormatInt(now.Add(24*time.Hour).Unix(), 10) + "&bucket_timestamp=" + strconv.FormatInt(time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Unix(), 10) + + for _, dimension := range []string{"team&id=" + strconv.FormatUint(uint64(team.ID), 10), "label&id=" + strconv.FormatUint(uint64(label.ID), 10)} { + w := httptest.NewRecorder() + path := "/api/v2/accounts/" + strconv.FormatUint(uint64(s.accountID), 10) + "/reports/drilldown?metric=incoming_messages_count&type=" + dimension + "&group_by=day&" + base + s.router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + s.Equal(http.StatusOK, w.Code, w.Body.String()) + result := assertChatwootJSONObject(s.T(), w.Body.String(), []string{"meta", "payload"}) + s.Equal(float64(2), result["meta"].(map[string]any)["total_count"]) + } + + for metric, want := range map[string]float64{"bot_resolutions_count": 1, "bot_handoffs_count": 1} { + w := httptest.NewRecorder() + path := "/api/v2/accounts/" + strconv.FormatUint(uint64(s.accountID), 10) + "/reports/drilldown?metric=" + metric + "&type=team&id=" + strconv.FormatUint(uint64(team.ID), 10) + "&group_by=day&" + base + s.router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + s.Equal(http.StatusOK, w.Code, w.Body.String()) + result := assertChatwootJSONObject(s.T(), w.Body.String(), []string{"meta", "payload"}) + s.Equal(want, result["meta"].(map[string]any)["total_count"]) + } + + other := &model.Account{Name: "Other"} + s.Require().NoError(s.db.Create(other).Error) + foreignTeam := &model.Team{AccountID: other.ID, Name: "Foreign"} + s.Require().NoError(s.db.Create(foreignTeam).Error) + w := httptest.NewRecorder() + path := "/api/v2/accounts/" + strconv.FormatUint(uint64(s.accountID), 10) + "/reports/drilldown?metric=conversations_count&type=team&id=" + strconv.FormatUint(uint64(foreignTeam.ID), 10) + "&group_by=day&" + base + s.router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + s.Equal(http.StatusNotFound, w.Code) + w = httptest.NewRecorder() + s.router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, strings.Replace(path, "group_by=day", "group_by=quarter", 1), nil)) + s.Equal(http.StatusUnprocessableEntity, w.Code) + + bucket := time.Date(2026, time.January, 31, 16, 0, 0, 0, time.UTC) + w = httptest.NewRecorder() + path = "/api/v2/accounts/" + strconv.FormatUint(uint64(s.accountID), 10) + "/reports/drilldown?metric=conversations_count&type=account&group_by=month&timezone_offset=8&since=" + strconv.FormatInt(bucket.Add(-time.Hour).Unix(), 10) + "&until=" + strconv.FormatInt(bucket.AddDate(0, 2, 0).Unix(), 10) + "&bucket_timestamp=" + strconv.FormatInt(bucket.Unix(), 10) + s.router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + s.Equal(http.StatusOK, w.Code, w.Body.String()) + result := assertChatwootJSONObject(s.T(), w.Body.String(), []string{"meta", "payload"}) + s.Equal(float64(time.Date(2026, time.February, 28, 16, 0, 0, 0, time.UTC).Unix()), result["meta"].(map[string]any)["bucket"].(map[string]any)["until"]) +} + // ========== parseAccountID / parseDateRange edge cases ========== func (s *AnalyticsHandlerTestSuite) TestSummary_InvalidAccountID() { diff --git a/backend/internal/handler/api/v1/article_handler.go b/backend/internal/handler/api/v1/article_handler.go index 085de374..e6ed51ed 100644 --- a/backend/internal/handler/api/v1/article_handler.go +++ b/backend/internal/handler/api/v1/article_handler.go @@ -853,11 +853,13 @@ func articlePayload(article *model.Article) gin.H { } func articleCategoryPayload(article *model.Article) gin.H { - payload := gin.H{"id": article.CategoryID, "name": nil, "slug": nil, "locale": nil} + payload := gin.H{"id": article.CategoryID, "name": nil, "slug": nil, "locale": nil, "icon": nil, "icon_color": nil} if article.Category != nil { payload["name"] = article.Category.Name payload["slug"] = article.Category.Slug payload["locale"] = article.Category.Locale + payload["icon"] = article.Category.Icon + payload["icon_color"] = article.Category.IconColor } return payload } diff --git a/backend/internal/handler/api/v1/assignable_agent_handler.go b/backend/internal/handler/api/v1/assignable_agent_handler.go index 95a6b6dd..01215398 100644 --- a/backend/internal/handler/api/v1/assignable_agent_handler.go +++ b/backend/internal/handler/api/v1/assignable_agent_handler.go @@ -80,10 +80,27 @@ func (h *AssignableAgentHandler) List(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"payload": serializeAssignableAgents(accountID, agents)}) + includeAgentBots := c.Query("include_agent_bots") != "" + payload := serializeAssignableAgents(accountID, agents, includeAgentBots) + if includeAgentBots { + bots, err := h.svc.GetAssignableAgentBots(c.Request.Context(), accountID) + if err != nil { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to list assignable agent bots") + return + } + for i := range bots { + bot := serializeAgentBotSlim(&bots[i]) + bot["assignee_type"] = "AgentBot" + bot["icon"] = "i-lucide-bot" + bot["availability_status"] = "offline" + payload = append(payload, bot) + } + } + c.JSON(http.StatusOK, gin.H{"payload": payload}) } -func serializeAssignableAgents(accountID uint, agents []service.AssignableAgentDTO) []map[string]any { +func serializeAssignableAgents(accountID uint, agents []service.AssignableAgentDTO, includeAgentBots ...bool) []map[string]any { + includeType := len(includeAgentBots) > 0 && includeAgentBots[0] payload := make([]map[string]any, 0, len(agents)) for i := range agents { agent := agents[i] @@ -99,7 +116,7 @@ func serializeAssignableAgents(accountID uint, agents []service.AssignableAgentD if agent.CustomRoleID != 0 { customRoleID = agent.CustomRoleID } - payload = append(payload, map[string]any{ + item := map[string]any{ "id": agent.ID, "account_id": accountID, "availability_status": agent.AvailabilityStatus, @@ -112,7 +129,11 @@ func serializeAssignableAgents(accountID uint, agents []service.AssignableAgentD "role": agent.Role, "thumbnail": agent.AvatarURL, "custom_role_id": customRoleID, - }) + } + if includeType { + item["assignee_type"] = "User" + } + payload = append(payload, item) } return payload } diff --git a/backend/internal/handler/api/v1/assignable_agent_handler_test.go b/backend/internal/handler/api/v1/assignable_agent_handler_test.go index dfa73052..0d33c29d 100644 --- a/backend/internal/handler/api/v1/assignable_agent_handler_test.go +++ b/backend/internal/handler/api/v1/assignable_agent_handler_test.go @@ -51,6 +51,7 @@ func (s *AssignableAgentHandlerTestSuite) SetupSuite() { &model.AccountUser{}, &model.Inbox{}, &model.InboxMember{}, + &model.AgentBot{}, ), "failed to auto-migrate models") s.db = db @@ -305,6 +306,29 @@ func (s *AssignableAgentHandlerTestSuite) TestList_StandaloneResourceUsesFronten } } +func (s *AssignableAgentHandlerTestSuite) TestList_IncludeAgentBotsAddsTypedOwners() { + bot := &model.AgentBot{AccountID: &s.account.ID, Name: "Triage bot", AvatarURL: "https://example.test/bot.png", BotType: "webhook"} + s.Require().NoError(s.db.Create(bot).Error) + s.T().Cleanup(func() { s.db.Delete(bot) }) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/assignable_agents?inbox_ids[]=%d&include_agent_bots=true", s.account.ID, s.inbox1.ID), nil) + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code, w.Body.String()) + payload := s.decodeAssignablePayload(w) + foundBot := false + for _, raw := range payload { + owner := raw.(map[string]any) + s.Contains(owner, "assignee_type") + if owner["assignee_type"] == "AgentBot" { + foundBot = true + s.Equal(float64(bot.ID), owner["id"]) + s.Equal("i-lucide-bot", owner["icon"]) + s.Equal("offline", owner["availability_status"]) + } + } + s.True(foundBot) +} + func (s *AssignableAgentHandlerTestSuite) TestList_MultipleInboxIDsQueryParams_NoIntersection() { // Request inbox1 + inbox3 via query param. inbox3 has no members. // Intersection of {user1, user2} ∩ {} = {} (empty). diff --git a/backend/internal/handler/api/v1/auth_handler.go b/backend/internal/handler/api/v1/auth_handler.go index 4df56dec..ab925781 100644 --- a/backend/internal/handler/api/v1/auth_handler.go +++ b/backend/internal/handler/api/v1/auth_handler.go @@ -158,6 +158,10 @@ func (h *AuthHandler) ChatwootSignIn(c *gin.Context) { }) return } + if err := h.trackChatwootSession(c, output); err != nil { + response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create session") + return + } h.setChatwootAuthHeaders(c, output) profile, err := h.chatwootUserPayload(c, output.User.ID, output.AccountID) @@ -212,7 +216,7 @@ func (h *AuthHandler) ChatwootSignOut(c *gin.Context) { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "invalid token") return } - if err := h.authService.Logout(c.Request.Context(), output.User.ID); err != nil { + if err := h.authService.RevokeChatwootSession(c.Request.Context(), output.User.ID, c.GetHeader("client")); err != nil { response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "Logout failed") return } @@ -357,6 +361,10 @@ func (h *AuthHandler) ConfirmResetPassword(c *gin.Context) { c.JSON(http.StatusUnprocessableEntity, gin.H{"message": err.Error(), "redirect_url": "/"}) return } + if err := h.trackChatwootSession(c, output); err != nil { + response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create session") + return + } h.setChatwootAuthHeaders(c, output) data, err := h.chatwootUserPayload(c, output.User.ID, output.AccountID) @@ -404,6 +412,10 @@ func (h *AuthHandler) ChatwootConfirmEmail(c *gin.Context) { c.JSON(http.StatusUnprocessableEntity, gin.H{"message": err.Error(), "redirect_url": "/"}) return } + if err := h.trackChatwootSession(c, output); err != nil { + response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create session") + return + } h.setChatwootAuthHeaders(c, output) data, err := h.chatwootUserPayload(c, output.User.ID, output.AccountID) @@ -533,10 +545,18 @@ func (h *AuthHandler) setChatwootAuthHeaders(c *gin.Context, output *service.Log return } c.Header("access-token", output.TokenPair.AccessToken) - c.Header("client", output.TokenPair.RefreshToken) + clientID := output.ClientID + if clientID == "" { + clientID = output.TokenPair.RefreshToken + } + c.Header("client", clientID) c.Header("expiry", strconv.FormatInt(output.TokenPair.ExpiresAt.Unix(), 10)) } +func (h *AuthHandler) trackChatwootSession(c *gin.Context, output *service.LoginOutput) error { + return h.authService.TrackChatwootSession(c.Request.Context(), output, c.GetHeader("client"), c.ClientIP(), c.GetHeader("User-Agent")) +} + func extractChatwootAccessToken(c *gin.Context) string { if token := strings.TrimSpace(c.GetHeader("access-token")); token != "" { return token diff --git a/backend/internal/handler/api/v1/auth_handler_test.go b/backend/internal/handler/api/v1/auth_handler_test.go index 8d638b7e..7a7b1ddd 100644 --- a/backend/internal/handler/api/v1/auth_handler_test.go +++ b/backend/internal/handler/api/v1/auth_handler_test.go @@ -28,7 +28,7 @@ func setupChatwootAuthTest(t *testing.T) (*gin.Engine, *gorm.DB, *model.User) { 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.User{}, &model.AccountUser{}, &model.AccessToken{})) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{}, &model.AccessToken{}, &model.UserSession{})) account := &model.Account{Name: "Auth Account", Status: "active", OnboardingStep: "invite_team"} require.NoError(t, db.Create(account).Error) @@ -75,10 +75,11 @@ func TestRegisterAuthRoutesDoesNotExposeSelfRegistration(t *testing.T) { } func TestChatwootAuthSignInReturnsDeviseHeadersAndUserPayload(t *testing.T) { - router, _, _ := setupChatwootAuthTest(t) + router, db, user := setupChatwootAuthTest(t) body, _ := json.Marshal(map[string]string{"email": " AUTH@example.com ", "password": "password123"}) req, _ := http.NewRequest(http.MethodPost, "/auth/sign_in", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) Chrome/126.0.0.0 Safari/537.36") w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -94,6 +95,10 @@ func TestChatwootAuthSignInReturnsDeviseHeadersAndUserPayload(t *testing.T) { require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload)) data := payload["data"].(map[string]any) assertChatwootAuthUserFixture(t, data) + var session model.UserSession + require.NoError(t, db.Where("user_id = ? AND client_id = ?", user.ID, w.Header().Get("client")).First(&session).Error) + require.Equal(t, "Chrome", session.BrowserName) + require.Equal(t, "Linux", session.PlatformName) } func assertChatwootAuthUserFixture(t *testing.T, data map[string]any) { diff --git a/backend/internal/handler/api/v1/captain_assistant_handler.go b/backend/internal/handler/api/v1/captain_assistant_handler.go index 2f1815ca..a8000eb8 100644 --- a/backend/internal/handler/api/v1/captain_assistant_handler.go +++ b/backend/internal/handler/api/v1/captain_assistant_handler.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "strconv" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/model" @@ -158,6 +159,99 @@ func (h *CaptainAssistantHandler) List(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"payload": payload, "meta": gin.H{"total_count": count, "page": 1}}) } +// Stats returns the current and previous Captain overview metrics. +func (h *CaptainAssistantHandler) Stats(c *gin.Context) { + accountID := parseAccountIDParam(c) + assistantID, err := parseUintParam(c, "assistant_id") + if accountID == 0 || err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid assistant") + return + } + offset, err := strconv.ParseFloat(c.DefaultQuery("timezone_offset", "0"), 64) + if err != nil { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrBadRequest, "invalid timezone_offset") + return + } + stats, err := h.svc.Stats(c.Request.Context(), accountID, assistantID, c.Query("range"), offset) + if err != nil { + handleServiceError(c, err) + return + } + c.JSON(http.StatusOK, stats) +} + +// Summary returns the message consumed by the Captain overview summary card. +func (h *CaptainAssistantHandler) Summary(c *gin.Context) { + accountID := parseAccountIDParam(c) + assistantID, err := parseUintParam(c, "assistant_id") + if accountID == 0 || err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid assistant") + return + } + offset, err := strconv.ParseFloat(c.DefaultQuery("timezone_offset", "0"), 64) + if err != nil { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrBadRequest, "invalid timezone_offset") + return + } + message, err := h.svc.Summary(c.Request.Context(), accountID, assistantID, getUserID(c), c.Query("range"), offset) + if err != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": message}) +} + +// Drilldown lists the conversations behind a Captain overview metric. +func (h *CaptainAssistantHandler) Drilldown(c *gin.Context) { + accountID := parseAccountIDParam(c) + assistantID, err := parseUintParam(c, "assistant_id") + if accountID == 0 || err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid assistant") + return + } + offset, err := strconv.ParseFloat(c.DefaultQuery("timezone_offset", "0"), 64) + if err != nil { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrBadRequest, "invalid timezone_offset") + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "25")) + result, err := h.svc.Drilldown(c.Request.Context(), accountID, assistantID, service.CaptainDrilldownParams{Metric: c.Query("metric"), Range: c.Query("range"), TimezoneOffset: offset, Page: page, PerPage: perPage}) + if err != nil { + if err.Error() == "unsupported metric" { + c.Status(http.StatusUnprocessableEntity) + return + } + handleServiceError(c, err) + return + } + c.JSON(http.StatusOK, result) +} + +// CreateMessageReport records feedback for a Captain-authored message. +func (h *CaptainAssistantHandler) CreateMessageReport(c *gin.Context) { + accountID := parseAccountIDParam(c) + var req struct { + MessageID uint `json:"message_id" binding:"required"` + ReportReason string `json:"report_reason" binding:"required"` + Description string `json:"description"` + } + if accountID == 0 || c.ShouldBindJSON(&req) != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "invalid report") + return + } + report, err := h.svc.CreateMessageReport(c.Request.Context(), accountID, getUserID(c), req.MessageID, req.ReportReason, req.Description) + if err != nil { + if err.Error() == "Only Captain messages can be reported" || err.Error() == "invalid report_reason" { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + return + } + handleServiceError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"id": report.ID, "message_id": report.MessageID, "conversation_id": report.ConversationID, "user_id": report.UserID, "report_reason": report.ReportReason, "description": report.Description, "created_at": report.CreatedAt.Unix()}) +} + // GetConfig retrieves the assistant's JSONB config. // GET /api/v1/accounts/:account_id/captain_assistants/:id/config func (h *CaptainAssistantHandler) GetConfig(c *gin.Context) { diff --git a/backend/internal/handler/api/v1/captain_assistant_handler_test.go b/backend/internal/handler/api/v1/captain_assistant_handler_test.go index f6a59f4e..6c184cab 100644 --- a/backend/internal/handler/api/v1/captain_assistant_handler_test.go +++ b/backend/internal/handler/api/v1/captain_assistant_handler_test.go @@ -10,12 +10,15 @@ import ( "net/http/httptest" "strconv" "testing" + "time" + "github.com/alicebob/miniredis/v2" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/llm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" + "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" @@ -23,6 +26,10 @@ import ( ) func setupCaptainAssistantHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB) { + return setupCaptainAssistantHandlerTestWithSummaryProvider(t, nil) +} + +func setupCaptainAssistantHandlerTestWithSummaryProvider(t *testing.T, provider llm.Provider) (*gin.Engine, *gorm.DB) { t.Helper() gin.SetMode(gin.TestMode) dbName := fmt.Sprintf("file:%s?mode=memory&cache=private", t.Name()) @@ -30,9 +37,17 @@ func setupCaptainAssistantHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB) { require.NoError(t, err) require.NoError(t, db.AutoMigrate( &model.Account{}, + &model.User{}, &model.Inbox{}, + &model.Contact{}, + &model.Conversation{}, + &model.Message{}, + &model.ReportingEvent{}, &model.CaptainAssistant{}, &model.CaptainInbox{}, + &model.CaptainDocument{}, + &model.CaptainAssistantResponse{}, + &model.CaptainMessageReport{}, )) t.Cleanup(func() { sqlDB, _ := db.DB() @@ -43,10 +58,11 @@ func setupCaptainAssistantHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB) { inboxRepo := repository.NewCaptainInboxRepo(db) documentRepo := repository.NewCaptainDocumentRepo(db) responseRepo := repository.NewCaptainAssistantResponseRepo(db) - svc := service.NewCaptainAssistantService(assistantRepo, inboxRepo, documentRepo, responseRepo, nil) + svc := service.NewCaptainAssistantService(assistantRepo, inboxRepo, documentRepo, responseRepo, provider) handler := NewCaptainAssistantHandler(svc) router := gin.New() + router.Use(func(c *gin.Context) { c.Set("user_id", uint(9)); c.Next() }) assistants := router.Group("/api/v1/accounts/:account_id/captain/assistants") assistants.GET("/", handler.List) assistants.POST("/", handler.Create) @@ -58,9 +74,132 @@ func setupCaptainAssistantHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB) { assistants.GET("/:assistant_id/inboxes", handler.ListInboxes) assistants.POST("/:assistant_id/inboxes", handler.AssociateInbox) assistants.DELETE("/:assistant_id/inboxes/:inbox_id", handler.DissociateInbox) + assistants.GET("/:assistant_id/stats", handler.Stats) + assistants.GET("/:assistant_id/summary", handler.Summary) + assistants.GET("/:assistant_id/drilldown", handler.Drilldown) + router.POST("/api/v1/accounts/:account_id/captain/message_reports", handler.CreateMessageReport) return router, db } +func TestCaptainAssistantHandler_OverviewDrilldownAndMessageReportContracts(t *testing.T) { + router, db := setupCaptainAssistantHandlerTestWithSummaryProvider(t, &captainPlaygroundFakeProvider{content: "Captain performance is improving."}) + account := seedCaptainAssistantAccount(t, db, "Captain Reports") + assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Fin", Description: "Support", Config: json.RawMessage(`{}`), Status: model.AssistantStatusActive} + require.NoError(t, db.Create(assistant).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "Support", ChannelType: "api", ChannelID: 1} + require.NoError(t, db.Create(inbox).Error) + contact := &model.Contact{AccountID: account.ID, Name: "Ada"} + 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: "api", Channel: "api"} + require.NoError(t, db.Create(conversation).Error) + message := &model.Message{Base: model.Base{CreatedAt: time.Now().Add(-time.Hour)}, AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, SenderID: &assistant.ID, SenderType: "Captain::Assistant", Content: "Answer", ContentType: "text", MessageType: "outgoing"} + require.NoError(t, db.Create(message).Error) + require.NoError(t, db.Create(&model.ReportingEvent{AccountID: account.ID, Name: "conversation_captain_inference_resolved", ConversationID: &conversation.ID, EventStartTime: time.Now().Add(-time.Hour), EventEndTime: time.Now().Add(-30 * time.Minute)}).Error) + + base := fmt.Sprintf("/api/v1/accounts/%d/captain/assistants/%d", account.ID, assistant.ID) + w := captainAssistantJSONRequest(t, router, http.MethodGet, base+"/stats?range=7&timezone_offset=0", nil) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + stats := map[string]any{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &stats)) + for _, key := range []string{"conversations_handled", "auto_resolution_rate", "handoff_rate", "hours_saved", "reopen_rate", "conversation_depth", "knowledge"} { + assert.Contains(t, stats, key) + } + + w = captainAssistantJSONRequest(t, router, http.MethodGet, base+"/drilldown?metric=conversations_handled&range=7", nil) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + drilldown := map[string]any{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &drilldown)) + assert.Contains(t, drilldown, "meta") + assert.Len(t, drilldown["payload"], 1) + w = captainAssistantJSONRequest(t, router, http.MethodGet, base+"/drilldown?metric=hours_saved&range=7", nil) + require.Equal(t, http.StatusUnprocessableEntity, w.Code) + w = captainAssistantJSONRequest(t, router, http.MethodGet, base+"/summary?range=7", nil) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + summary := map[string]any{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &summary)) + assert.NotEmpty(t, summary["message"]) + + w = captainAssistantJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/captain/message_reports", account.ID), map[string]any{"message_id": message.ID, "report_reason": "incorrect_information", "description": "Wrong"}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + report := map[string]any{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &report)) + assert.Equal(t, "incorrect_information", report["report_reason"]) + assert.Equal(t, float64(message.ID), report["message_id"]) + human := &model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, SenderType: "User", Content: "Human", ContentType: "text", MessageType: "outgoing"} + require.NoError(t, db.Create(human).Error) + w = captainAssistantJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/captain/message_reports", account.ID), map[string]any{"message_id": human.ID, "report_reason": "other"}) + require.Equal(t, http.StatusUnprocessableEntity, w.Code) + otherAccount := seedCaptainAssistantAccount(t, db, "Other") + w = captainAssistantJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/captain/message_reports", otherAccount.ID), map[string]any{"message_id": message.ID, "report_reason": "other"}) + require.Equal(t, http.StatusNotFound, w.Code) +} + +func TestCaptainAssistantSummaryCachesOnlySuccessfulLLMResponses(t *testing.T) { + _, db := setupCaptainAssistantHandlerTest(t) + account := seedCaptainAssistantAccount(t, db, "Captain Summary") + user := &model.User{Name: "Ada Lovelace", Email: "ada-summary@example.com"} + require.NoError(t, db.Create(user).Error) + assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Fin", Status: model.AssistantStatusActive} + require.NoError(t, db.Create(assistant).Error) + provider := &captainPlaygroundFakeProvider{content: "Ada, Captain performance is improving."} + mini := miniredis.RunT(t) + cache := redis.NewClient(&redis.Options{Addr: mini.Addr()}) + t.Cleanup(func() { _ = cache.Close() }) + svc := service.NewCaptainAssistantService(repository.NewCaptainAssistantRepo(db), repository.NewCaptainInboxRepo(db), repository.NewCaptainDocumentRepo(db), repository.NewCaptainAssistantResponseRepo(db), provider, cache) + + first, err := svc.Summary(context.Background(), account.ID, assistant.ID, user.ID, "7", 0) + require.NoError(t, err) + second, err := svc.Summary(context.Background(), account.ID, assistant.ID, user.ID, "7", 0) + require.NoError(t, err) + require.Equal(t, first, second) + require.Equal(t, 1, provider.calls) + + failing := service.NewCaptainAssistantService(repository.NewCaptainAssistantRepo(db), repository.NewCaptainInboxRepo(db), repository.NewCaptainDocumentRepo(db), repository.NewCaptainAssistantResponseRepo(db), &captainPlaygroundFakeProvider{err: errors.New("provider unavailable")}) + _, err = failing.Summary(context.Background(), account.ID, assistant.ID, user.ID, "30", 0) + require.ErrorContains(t, err, "provider unavailable") +} + +func TestCaptainAssistantStatsAndDrilldownUseExactResolvedReopenCohort(t *testing.T) { + _, db := setupCaptainAssistantHandlerTest(t) + account := seedCaptainAssistantAccount(t, db, "Captain Cohort") + assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Fin", Status: model.AssistantStatusActive} + require.NoError(t, db.Create(assistant).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "Support", ChannelType: "api", ChannelID: 1} + require.NoError(t, db.Create(inbox).Error) + contact := &model.Contact{AccountID: account.ID, Name: "Ada"} + require.NoError(t, db.Create(contact).Error) + now := time.Now().UTC() + conversations := make([]model.Conversation, 2) + for i := range conversations { + conversations[i] = model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", ChannelType: "api", Channel: "api"} + require.NoError(t, db.Create(&conversations[i]).Error) + require.NoError(t, db.Create(&model.Message{Base: model.Base{CreatedAt: now.Add(-4 * time.Hour)}, AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversations[i].ID, SenderID: &assistant.ID, SenderType: "Captain::Assistant", MessageType: "outgoing"}).Error) + } + seedEvent := func(conversationID uint, name string, createdAt, endAt time.Time, value float64) { + require.NoError(t, db.Create(&model.ReportingEvent{Base: model.Base{CreatedAt: createdAt}, AccountID: account.ID, ConversationID: &conversationID, Name: name, Value: value, EventEndTime: endAt}).Error) + } + seedEvent(conversations[0].ID, "conversation_bot_resolved", now.Add(-3*time.Hour), now.Add(-3*time.Hour), 0) + seedEvent(conversations[0].ID, "conversation_bot_handoff", now.Add(-2*time.Hour), now.Add(-2*time.Hour), 0) + seedEvent(conversations[0].ID, "conversation_opened", now.Add(-time.Hour), now.Add(-time.Hour), 1) + seedEvent(conversations[1].ID, "conversation_captain_inference_resolved", now.Add(-3*time.Hour), now.Add(-3*time.Hour), 0) + seedEvent(conversations[1].ID, "conversation_bot_handoff", now.Add(-2*time.Hour), now.Add(-2*time.Hour), 0) + seedEvent(conversations[1].ID, "conversation_opened", now.Add(-4*time.Hour), now.Add(-4*time.Hour), 1) + seedEvent(conversations[1].ID, "conversation_opened", now.Add(-time.Hour), now.Add(-time.Hour), 1) + svc := service.NewCaptainAssistantService(repository.NewCaptainAssistantRepo(db), repository.NewCaptainInboxRepo(db), repository.NewCaptainDocumentRepo(db), repository.NewCaptainAssistantResponseRepo(db), nil) + + stats, err := svc.Stats(context.Background(), account.ID, assistant.ID, "7", 0) + require.NoError(t, err) + require.Equal(t, 50.0, stats.AutoResolutionRate.Current) + require.Equal(t, 100.0, stats.ReopenRate.Current) + for _, metric := range []string{"auto_resolution_rate", "reopen_rate"} { + result, drilldownErr := svc.Drilldown(context.Background(), account.ID, assistant.ID, service.CaptainDrilldownParams{Metric: metric, Range: "7"}) + require.NoError(t, drilldownErr) + require.Len(t, result.Payload, 1) + require.Equal(t, conversations[1].ID, result.Payload[0]["conversation"].(map[string]any)["id"]) + } +} + func setupCaptainAssistantHandlerTestWithProvider(t *testing.T, provider llm.Provider) (*gin.Engine, *gorm.DB) { t.Helper() gin.SetMode(gin.TestMode) @@ -319,10 +458,12 @@ func TestCaptainAssistantHandler_PlaygroundV2ProviderErrorReturnsChatwootFallbac type captainPlaygroundFakeProvider struct { content string err error + calls int lastRequest llm.ChatRequest } func (p *captainPlaygroundFakeProvider) ChatCompletion(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) { + p.calls++ p.lastRequest = req if p.err != nil { return nil, p.err diff --git a/backend/internal/handler/api/v1/category_handler.go b/backend/internal/handler/api/v1/category_handler.go index 999b03dc..156be842 100644 --- a/backend/internal/handler/api/v1/category_handler.go +++ b/backend/internal/handler/api/v1/category_handler.go @@ -278,6 +278,7 @@ func categoryPayload(category *model.Category, currentLocale string) gin.H { "position": category.Position, "account_id": category.AccountID, "icon": category.Icon, + "icon_color": category.IconColor, "related_categories": relatedCategoryPayloads(category.RelatedCategories), "meta": gin.H{"articles_count": categoryArticleCount(category.Articles, currentLocale)}, } @@ -321,6 +322,8 @@ func associatedCategoryPayload(category *model.Category) gin.H { "description": category.Description, "position": category.Position, "account_id": category.AccountID, + "icon": category.Icon, + "icon_color": category.IconColor, } } diff --git a/backend/internal/handler/api/v1/chatwoot_415_serializer_test.go b/backend/internal/handler/api/v1/chatwoot_415_serializer_test.go new file mode 100644 index 00000000..368cbbf2 --- /dev/null +++ b/backend/internal/handler/api/v1/chatwoot_415_serializer_test.go @@ -0,0 +1,83 @@ +package v1 + +import ( + "context" + "encoding/json" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/model" + "github.com/gochat/gochat/internal/repository" + "github.com/gochat/gochat/internal/service" + "github.com/stretchr/testify/require" + "gorm.io/datatypes" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestChatwoot415SerializerFields(t *testing.T) { + account := &model.Account{Base: model.Base{ID: 1}, CustomAttributes: datatypes.JSON(`{"billing_currency":"brl","help_center_generation_id":"gen-1"}`)} + accountPayload := serializeAccount(account) + custom := accountPayload["custom_attributes"].(map[string]any) + require.Equal(t, "brl", custom["billing_currency"]) + require.Equal(t, "gen-1", custom["help_center_generation_id"]) + + category := &model.Category{Base: model.Base{ID: 2}, AccountID: 1, Name: "FAQ", Slug: "faq", Locale: "en", Icon: "book", IconColor: "#123456"} + require.Equal(t, "#123456", categoryPayload(category, "en")["icon_color"]) + article := &model.Article{CategoryID: &category.ID, Category: category} + require.Equal(t, "#123456", articleCategoryPayload(article)["icon_color"]) + + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest("GET", "/", nil) + team := &model.Team{Base: model.Base{ID: 3}, AccountID: 1, Name: "Support", Icon: "users", IconColor: "#abcdef"} + teamPayload := serializeTeamForAccount(ctx, nil, team) + require.Equal(t, "users", teamPayload["icon"]) + require.Equal(t, "#abcdef", teamPayload["icon_color"]) + + portal := &model.Portal{Base: model.Base{ID: 4}, AccountID: 1, Name: "Help", Slug: "help", Locale: "en", PortalConfiguration: json.RawMessage(`{"locale_translations":{"en":{"header":"Help"}}}`)} + portalConfigPayload := portalPayload(portal, "en", 0)["config"].(gin.H) + require.Equal(t, "Help", portalConfigPayload["locale_translations"].(map[string]any)["en"].(map[string]any)["header"]) + + inbox := &model.Inbox{Base: model.Base{ID: 5}, AccountID: 1, Name: "Voice", ChannelType: "whatsapp", ChannelConfig: `{"voice_enabled":true,"inbound_calls_enabled":false}`} + require.Equal(t, false, serializeInbox(inbox, nil, true)["inbound_calls_enabled"]) + + frt, nrt, rt := time.Now(), time.Now().Add(time.Minute), time.Now().Add(2*time.Minute) + applied := &model.AppliedSLA{FRTTargetAt: &frt, NRTTargetAt: &nrt, RTTargetAt: &rt} + slaPayload := serializeAppliedSlaReportApplied(applied) + require.Equal(t, frt.Unix(), slaPayload["sla_frt_due_at"]) + require.Equal(t, nrt.Unix(), slaPayload["sla_nrt_due_at"]) + require.Equal(t, rt.Unix(), slaPayload["sla_rt_due_at"]) +} + +func TestChatwoot415AssignmentPolicyFieldRoundTrip(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.AssignmentPolicy{}, &model.InboxAssignmentPolicy{})) + account := &model.Account{Name: "Account"} + require.NoError(t, db.Create(account).Error) + hours := 24 + svc := service.NewAssignmentPolicyService(repository.NewAssignmentPolicyRepo(db), repository.NewInboxAssignmentPolicyRepo(db), nil) + policy, err := svc.CreateAccountPolicy(context.Background(), account.ID, service.CreatePolicyRequest{Name: "Fresh", ExcludeOlderThanHours: &hours}) + require.NoError(t, err) + payload, err := svc.SerializePolicy(context.Background(), policy) + require.NoError(t, err) + require.Equal(t, &hours, policy.ExcludeOlderThanHours) + require.Equal(t, &hours, payload["exclude_older_than_hours"]) +} + +func TestChatwoot415CaptainMessageSenderSerializer(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.CaptainAssistant{}, &model.Conversation{}, &model.Message{}, &model.Attachment{}, &model.Call{})) + account := &model.Account{Name: "Account"} + require.NoError(t, db.Create(account).Error) + assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Fin", Description: "AI assistant", Config: json.RawMessage(`{}`)} + require.NoError(t, db.Create(assistant).Error) + message := &model.Message{AccountID: account.ID, SenderID: &assistant.ID, SenderType: "Captain::Assistant", Content: "Answer", MessageType: "outgoing"} + payload := serializeMessage(context.Background(), db, message, nil) + require.Equal(t, "captain_assistant", payload.Sender["type"]) + require.Equal(t, "Fin", payload.Sender["name"]) + require.Equal(t, "AI assistant", payload.Sender["description"]) +} diff --git a/backend/internal/handler/api/v1/conversation_handler.go b/backend/internal/handler/api/v1/conversation_handler.go index 11f7065d..f7739c45 100644 --- a/backend/internal/handler/api/v1/conversation_handler.go +++ b/backend/internal/handler/api/v1/conversation_handler.go @@ -265,6 +265,20 @@ func (h *ConversationHandler) AssignAgent(c *gin.Context) { if !ok { return } + if req.AssigneeType == "AgentBot" { + _, bot, svcErr := h.conversationSvc.AssignAgentBot(c.Request.Context(), accountID, conversation.ID, req.AssigneeID) + if svcErr != nil { + handleServiceError(c, svcErr) + return + } + if bot == nil { + c.JSON(http.StatusOK, nil) + return + } + c.JSON(http.StatusOK, serializeAgentBotSlim(bot)) + return + } + conversation, svcErr := h.conversationSvc.AssignAgent(c.Request.Context(), accountID, conversation.ID, req.AssigneeID) if svcErr != nil { handleServiceError(c, svcErr) diff --git a/backend/internal/handler/api/v1/conversation_handler_crud_test.go b/backend/internal/handler/api/v1/conversation_handler_crud_test.go index 021c6614..d31721c5 100644 --- a/backend/internal/handler/api/v1/conversation_handler_crud_test.go +++ b/backend/internal/handler/api/v1/conversation_handler_crud_test.go @@ -89,6 +89,7 @@ func (s *ConversationCrudTestSuite) SetupSuite() { &model.SlaEvent{}, &model.CaptainAssistant{}, &model.CaptainInbox{}, + &model.AgentBot{}, &model.CustomAttributeDefinition{}, ) s.Require().NoError(err) @@ -700,6 +701,48 @@ func (s *ConversationCrudTestSuite) TestAssignAgent_Success() { assert.Equal(s.T(), float64(user.ID), resp["id"]) } +func (s *ConversationCrudTestSuite) TestAssignAgentBot_MutuallyExclusiveAndAccountScoped() { + bot := &model.AgentBot{AccountID: &s.testAccount.ID, Name: "Triage bot", BotType: "webhook", AccessToken: "triage-token", Secret: "triage-secret"} + s.Require().NoError(s.db.Create(bot).Error) + body, _ := json.Marshal(map[string]any{"assignee_id": bot.ID, "assignee_type": "AgentBot"}) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, s.convURL(s.testConv.ID)+"/assign", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code, w.Body.String()) + var assigned model.Conversation + s.Require().NoError(s.db.First(&assigned, s.testConv.ID).Error) + s.Nil(assigned.AssigneeID) + s.Require().NotNil(assigned.AssigneeAgentBotID) + s.Equal(bot.ID, *assigned.AssigneeAgentBotID) + + user := &model.User{Name: "Agent", Email: "bot-switch@example.com"} + s.Require().NoError(s.db.Create(user).Error) + s.Require().NoError(s.db.Create(&model.InboxMember{InboxID: s.testInbox.ID, UserID: user.ID}).Error) + s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: s.testAccount.ID, UserID: user.ID, Role: "agent"}).Error) + body, _ = json.Marshal(map[string]any{"assignee_id": user.ID, "assignee_type": "User"}) + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, s.convURL(s.testConv.ID)+"/assign", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code, w.Body.String()) + s.Require().NoError(s.db.First(&assigned, s.testConv.ID).Error) + s.Require().NotNil(assigned.AssigneeID) + s.Equal(user.ID, *assigned.AssigneeID) + s.Nil(assigned.AssigneeAgentBotID) + + other := &model.Account{Name: "Other"} + s.Require().NoError(s.db.Create(other).Error) + foreignBot := &model.AgentBot{AccountID: &other.ID, Name: "Foreign", BotType: "webhook", AccessToken: "foreign-token", Secret: "foreign-secret"} + s.Require().NoError(s.db.Create(foreignBot).Error) + body, _ = json.Marshal(map[string]any{"assignee_id": foreignBot.ID, "assignee_type": "AgentBot"}) + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, s.convURL(s.testConv.ID)+"/assign", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + s.Equal(http.StatusNotFound, w.Code) +} + func (s *ConversationCrudTestSuite) TestAssignAgent_InvalidAccountID() { body, _ := json.Marshal(map[string]interface{}{ "assignee_id": 1, diff --git a/backend/internal/handler/api/v1/conversation_serializer.go b/backend/internal/handler/api/v1/conversation_serializer.go index 3c1ee981..79845467 100644 --- a/backend/internal/handler/api/v1/conversation_serializer.go +++ b/backend/internal/handler/api/v1/conversation_serializer.go @@ -452,6 +452,11 @@ func serializeMessage(ctx context.Context, db *gorm.DB, message *model.Message, if err := db.WithContext(ctx).First(&bot, *message.SenderID).Error; err == nil { payload.Sender = serializeAgentBotSender(&bot) } + case "captain_assistant": + var assistant model.CaptainAssistant + if err := db.WithContext(ctx).First(&assistant, *message.SenderID).Error; err == nil { + payload.Sender = serializeCaptainAssistantSender(&assistant) + } default: var user model.User if err := db.WithContext(ctx).First(&user, *message.SenderID).Error; err == nil { @@ -649,6 +654,13 @@ func serializeMessageSender(ctx context.Context, db *gorm.DB, message *model.Mes } return nil } + if senderType == "captain_assistant" { + var assistant model.CaptainAssistant + if err := db.WithContext(ctx).First(&assistant, *message.SenderID).Error; err == nil { + return serializeCaptainAssistantSender(&assistant) + } + return nil + } var user model.User if err := db.WithContext(ctx).First(&user, *message.SenderID).Error; err == nil { return serializeUser(&user, message.AccountID) @@ -662,6 +674,8 @@ func normalizedSenderType(senderType string) string { return "contact" case "agentbot", "agent_bot": return "agent_bot" + case "captain::assistant", "captainassistant", "captain_assistant": + return "captain_assistant" default: return "user" } @@ -731,6 +745,11 @@ func serializeAgentBotSender(bot *model.AgentBot) map[string]any { } } +func serializeCaptainAssistantSender(assistant *model.CaptainAssistant) map[string]any { + avatarURL := strings.TrimRight(os.Getenv("FRONTEND_URL"), "/") + "/assets/images/dashboard/captain/logo.svg" + return map[string]any{"id": assistant.ID, "name": assistant.Name, "avatar_url": avatarURL, "description": assistant.Description, "created_at": assistant.CreatedAt, "type": "captain_assistant"} +} + func serializeAgentBotSlim(bot *model.AgentBot) map[string]any { return map[string]any{ "id": bot.ID, diff --git a/backend/internal/handler/api/v1/enterprise_account_handler.go b/backend/internal/handler/api/v1/enterprise_account_handler.go index e5908c9b..462013e9 100644 --- a/backend/internal/handler/api/v1/enterprise_account_handler.go +++ b/backend/internal/handler/api/v1/enterprise_account_handler.go @@ -2,6 +2,7 @@ package v1 import ( "net/http" + "strings" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/service" @@ -82,10 +83,15 @@ func (h *EnterpriseAccountHandler) Subscription(c *gin.Context) { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return } - if err := h.svc.EnsureEnterpriseAccountCustomerCreationFlag(c.Request.Context(), accountID, getUserID(c)); err != nil { + payload, selectionRequired, err := h.svc.EnterpriseSubscription(c.Request.Context(), getUserID(c), accountID) + if err != nil { response.AbortWithStatusError(c, http.StatusNotFound, response.ErrAccountNotFound, "account not found") return } + if selectionRequired { + c.JSON(http.StatusOK, payload) + return + } c.Status(http.StatusNoContent) } @@ -127,3 +133,34 @@ func (h *EnterpriseAccountHandler) TopupCheckout(c *gin.Context) { } c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Top-up checkout provider is not configured"}) } + +func (h *EnterpriseAccountHandler) SelectBillingCurrency(c *gin.Context) { + accountID := enterpriseAccountID(c) + var req struct { + Currency string `json:"currency" form:"currency"` + } + _ = c.ShouldBind(&req) + if accountID == 0 || strings.TrimSpace(req.Currency) == "" { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Invalid billing currency"}) + return + } + if err := h.svc.SelectBillingCurrency(c.Request.Context(), getUserID(c), accountID, req.Currency); err != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + return + } + c.Status(http.StatusNoContent) +} + +func (h *EnterpriseAccountHandler) TopupOptions(c *gin.Context) { + accountID := enterpriseAccountID(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") + return + } + payload, err := h.svc.EnterpriseTopupOptions(c.Request.Context(), getUserID(c), accountID) + if err != nil { + response.AbortWithStatusError(c, http.StatusNotFound, response.ErrAccountNotFound, "account not found") + return + } + c.JSON(http.StatusOK, payload) +} diff --git a/backend/internal/handler/api/v1/enterprise_account_handler_test.go b/backend/internal/handler/api/v1/enterprise_account_handler_test.go index 0b634ad8..09067c6e 100644 --- a/backend/internal/handler/api/v1/enterprise_account_handler_test.go +++ b/backend/internal/handler/api/v1/enterprise_account_handler_test.go @@ -247,6 +247,69 @@ func TestEnterpriseAccountSubscriptionPreservesExistingCustomerState(t *testing. require.NotContains(t, attrs, "is_creating_customer") } +func TestEnterpriseAccountBillingCurrencyAndTopupOptions(t *testing.T) { + router, db, account, user := setupEnterpriseAccountHandlerTest(t) + account.Locale = "pt_BR" + 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.InstallationConfig{Name: "ENABLE_MULTI_CURRENCY_BILLING", Value: "true"}).Error) + require.NoError(t, db.Create(&model.InstallationConfig{Name: "CAPTAIN_TOPUP_OPTIONS", Value: `{"brl":[{"credits":100,"amount":49.9}]}`}).Error) + + w := enterpriseAccountRequest(t, router, account.ID, http.MethodPost, "select_billing_currency", `{"currency":"BRL"}`) + require.Equal(t, http.StatusNoContent, w.Code, w.Body.String()) + require.NoError(t, db.First(account, account.ID).Error) + require.Equal(t, "brl", account.CustomAttributesMap()["billing_currency"]) + + w = enterpriseAccountRequest(t, router, account.ID, http.MethodGet, "topup_options", ``) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + require.Equal(t, float64(account.ID), body["id"]) + require.Equal(t, "brl", body["currency"]) + options := body["options"].([]any) + require.Len(t, options, 1) + require.Equal(t, "brl", options[0].(map[string]any)["currency"]) + + w = enterpriseAccountLiteralRequest(t, router, account.ID, http.MethodGet, "topup_options", ``) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) +} + +func TestEnterpriseAccountBillingCurrencyRejectsInvalidAndLockedWithoutMutation(t *testing.T) { + router, db, account, user := setupEnterpriseAccountHandlerTest(t) + account.Locale = "pt_BR" + 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.InstallationConfig{Name: "ENABLE_MULTI_CURRENCY_BILLING", Value: "true"}).Error) + w := enterpriseAccountRequest(t, router, account.ID, http.MethodPost, "select_billing_currency", `{"currency":"eur"}`) + require.Equal(t, http.StatusUnprocessableEntity, w.Code) + require.NoError(t, db.First(account, account.ID).Error) + require.NotContains(t, account.CustomAttributesMap(), "billing_currency") + require.NoError(t, account.SetCustomAttributesMap(map[string]any{"stripe_customer_id": "cus_locked"})) + require.NoError(t, db.Save(account).Error) + w = enterpriseAccountRequest(t, router, account.ID, http.MethodPost, "select_billing_currency", `{"currency":"usd"}`) + require.Equal(t, http.StatusUnprocessableEntity, w.Code) + require.NoError(t, db.First(account, account.ID).Error) + require.NotContains(t, account.CustomAttributesMap(), "billing_currency") +} + +func TestEnterpriseAccountSubscriptionRequiresCurrencySelectionForNewBRLAccount(t *testing.T) { + router, db, account, user := setupEnterpriseAccountHandlerTest(t) + account.Locale = "pt_BR" + 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.InstallationConfig{Name: "ENABLE_MULTI_CURRENCY_BILLING", Value: "true"}).Error) + + w := enterpriseAccountRequest(t, router, account.ID, http.MethodPost, "subscription", ``) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + require.Equal(t, true, body["currency_selection_required"]) + require.Equal(t, []any{"usd", "brl"}, body["currency_options"]) + require.Equal(t, "brl", body["suggested_currency"]) + require.NoError(t, db.First(account, account.ID).Error) + require.NotContains(t, account.CustomAttributesMap(), "is_creating_customer") +} + func TestEnterpriseAccountRejectsAccountOutsideCurrentUser(t *testing.T) { router, _, account, _ := setupEnterpriseAccountHandlerTest(t) @@ -266,7 +329,7 @@ func setupEnterpriseAccountHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB, *mo sqlDB, _ := db.DB() _ = sqlDB.Close() }) - require.NoError(t, db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{}, &model.Conversation{}, &model.Inbox{}, &model.CaptainDocument{})) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{}, &model.Conversation{}, &model.Inbox{}, &model.CaptainDocument{}, &model.InstallationConfig{})) user := seedEnterpriseUser(t, db, "admin@example.com") account := &model.Account{Name: "Acme", Active: true, Status: "active", Locale: "en"} @@ -285,10 +348,14 @@ 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) + accounts.POST("/:account_id/select_billing_currency", handler.SelectBillingCurrency) + accounts.GET("/:account_id/topup_options", handler.TopupOptions) 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/select_billing_currency", handler.SelectBillingCurrency) + router.GET("/enterprise/api/v1/topup_options", handler.TopupOptions) router.POST("/enterprise/api/v1/toggle_deletion", handler.ToggleDeletion) return router, db, account, user } diff --git a/backend/internal/handler/api/v1/inbox_handler.go b/backend/internal/handler/api/v1/inbox_handler.go index 6081e872..7793b393 100644 --- a/backend/internal/handler/api/v1/inbox_handler.go +++ b/backend/internal/handler/api/v1/inbox_handler.go @@ -783,6 +783,31 @@ func (h *InboxHandler) DisableWhatsAppCalling(c *gin.Context) { h.handleWhatsAppCallingToggle(c, false) } +func (h *InboxHandler) SetInboundCalls(c *gin.Context) { + accountID := parseAccountIDParam(c) + inboxID, err := parseUintParam(c, "inbox_id") + if accountID == 0 || err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox"}) + return + } + var req struct { + Enabled bool `json:"inbound_calls_enabled"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := h.svc.SetInboundCalls(c.Request.Context(), accountID, inboxID, req.Enabled); err != nil { + if errors.Is(err, service.ErrInboxInboundCallsUnsupported) { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + return + } + handleServiceError(c, err) + return + } + c.Status(http.StatusOK) +} + func (h *InboxHandler) handleWhatsAppCallingToggle(c *gin.Context, enable bool) { accountID := parseAccountIDParam(c) if accountID == 0 { diff --git a/backend/internal/handler/api/v1/inbox_serializer.go b/backend/internal/handler/api/v1/inbox_serializer.go index f9b39355..067e4164 100644 --- a/backend/internal/handler/api/v1/inbox_serializer.go +++ b/backend/internal/handler/api/v1/inbox_serializer.go @@ -94,6 +94,7 @@ func serializeInbox(inbox *model.Inbox, db *gorm.DB, isAdmin bool) map[string]an payload["api_key_sid"] = configValue(config, "api_key_sid") } payload["voice_enabled"] = configValue(config, "voice_enabled") + payload["inbound_calls_enabled"] = configBoolDefault(config, "inbound_calls_enabled", true) voiceConfigured := configStringPresent(config, "twiml_app_sid") payload["voice_configured"] = voiceConfigured payload["has_api_key_secret"] = configStringPresent(config, "api_key_secret") @@ -138,6 +139,7 @@ func serializeInbox(inbox *model.Inbox, db *gorm.DB, isAdmin bool) map[string]an } payload["reauthorization_required"] = configValue(config, "reauthorization_required") payload["voice_enabled"] = configValue(config, "voice_enabled") + payload["inbound_calls_enabled"] = configBoolDefault(config, "inbound_calls_enabled", true) case "Channel::Line": payload["line_channel_id"] = configValue(config, "line_channel_id") payload["line_channel_secret"] = configValue(config, "line_channel_secret") diff --git a/backend/internal/handler/api/v1/portal_handler.go b/backend/internal/handler/api/v1/portal_handler.go index 39e995d9..d8fc74f8 100644 --- a/backend/internal/handler/api/v1/portal_handler.go +++ b/backend/internal/handler/api/v1/portal_handler.go @@ -370,10 +370,11 @@ func portalPayload(portal *model.Portal, locale string, currentUserID uint) gin. "archived": portal.Archived, "account_id": portal.AccountID, "config": gin.H{ - "allowed_locales": portalAllowedLocalePayloads(allowedLocales, draftLocales, portal.Articles, portal.Categories), - "default_locale": defaultLocale, - "layout": configString(config, "layout", "classic"), - "social_profiles": configMap(config, "social_profiles"), + "allowed_locales": portalAllowedLocalePayloads(allowedLocales, draftLocales, portal.Articles, portal.Categories), + "default_locale": defaultLocale, + "layout": configString(config, "layout", "classic"), + "social_profiles": configMap(config, "social_profiles"), + "locale_translations": configMap(config, "locale_translations"), }, "meta": portalMeta(portal, selectedArticles, defaultLocale, currentUserID), } diff --git a/backend/internal/handler/api/v1/profile_handler.go b/backend/internal/handler/api/v1/profile_handler.go index 5f99d116..a58f23df 100644 --- a/backend/internal/handler/api/v1/profile_handler.go +++ b/backend/internal/handler/api/v1/profile_handler.go @@ -4,6 +4,7 @@ import ( "encoding/json" "mime/multipart" "net/http" + "strconv" "strings" "github.com/gin-gonic/gin" @@ -49,6 +50,45 @@ func (h *ProfileHandler) Get(c *gin.Context) { c.JSON(http.StatusOK, user) } +func (h *ProfileHandler) ListSessions(c *gin.Context) { + userID := getUserID(c) + sessions, err := h.svc.ListUserSessions(c.Request.Context(), userID) + if err != nil { + handleServiceError(c, err) + return + } + currentClientID := c.GetHeader("client") + payload := make([]gin.H, 0, len(sessions)) + for i := range sessions { + session := sessions[i] + payload = append(payload, gin.H{ + "id": session.ID, "browser_name": session.BrowserName, "browser_version": session.BrowserVersion, + "device_name": session.DeviceName, "platform_name": session.PlatformName, "platform_version": session.PlatformVersion, + "ip_address": session.IPAddress, "city": session.City, "country": session.Country, "country_code": session.CountryCode, + "last_activity_at": session.LastActivityAt, "created_at": session.CreatedAt, "current": session.ClientID == currentClientID, + }) + } + c.JSON(http.StatusOK, payload) +} + +func (h *ProfileHandler) RevokeSession(c *gin.Context) { + userID := getUserID(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid session id") + return + } + if err := h.svc.RevokeUserSession(c.Request.Context(), userID, uint(id), c.GetHeader("client")); err != nil { + if strings.Contains(strings.ToLower(err.Error()), "current session") { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + return + } + handleServiceError(c, err) + return + } + c.Status(http.StatusOK) +} + // Update updates the current user's profile. // PUT /api/v1/profile func (h *ProfileHandler) Update(c *gin.Context) { diff --git a/backend/internal/handler/api/v1/profile_handler_test.go b/backend/internal/handler/api/v1/profile_handler_test.go index d8b8fd8d..b9341822 100644 --- a/backend/internal/handler/api/v1/profile_handler_test.go +++ b/backend/internal/handler/api/v1/profile_handler_test.go @@ -21,6 +21,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/logger" + "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/config" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" @@ -34,12 +35,13 @@ import ( type ProfileHandlerTestSuite struct { suite.Suite - router *gin.Engine - handler *ProfileHandler - db *gorm.DB - mailer *fakeProfileConfirmationMailer - user *model.User - account *model.Account + router *gin.Engine + handler *ProfileHandler + db *gorm.DB + mailer *fakeProfileConfirmationMailer + user *model.User + account *model.Account + refreshStore *auth.RefreshTokenStore userID uint accountID uint @@ -66,6 +68,7 @@ func (s *ProfileHandlerTestSuite) SetupSuite() { &model.CustomRole{}, &model.AccessToken{}, &model.InstallationConfig{}, + &model.UserSession{}, )) s.db = db @@ -103,7 +106,9 @@ func (s *ProfileHandlerTestSuite) SetupSuite() { accountUserRepo := repository.NewAccountUserRepo(db) accessTokenRepo := repository.NewAccessTokenRepo(db) installationConfigRepo := repository.NewInstallationConfigRepo(db) - profileSvc := service.NewProfileService(userRepo, accountUserRepo, accessTokenRepo, installationConfigRepo) + refreshStore := auth.NewRefreshTokenStore(nil, &config.JWTConfig{RefreshExpiryHours: 24}) + s.refreshStore = refreshStore + profileSvc := service.NewProfileService(userRepo, accountUserRepo, accessTokenRepo, installationConfigRepo, refreshStore) uploadCfg := &config.Config{} uploadCfg.Storage.LocalPath = s.T().TempDir() uploadCfg.Storage.MaxFileSize = 20 << 20 @@ -137,11 +142,14 @@ func (s *ProfileHandlerTestSuite) buildRouter() *gin.Engine { profile.PUT("/set_active_account", s.handler.SetActiveAccount) profile.POST("/resend_confirmation", s.handler.ResendConfirmation) profile.POST("/reset_access_token", s.handler.ResetAccessToken) + profile.GET("/sessions", s.handler.ListSessions) + profile.DELETE("/sessions/:id", s.handler.RevokeSession) return r } func (s *ProfileHandlerTestSuite) SetupTest() { + s.db.Where("user_id = ?", s.userID).Delete(&model.UserSession{}) passwordDigest, err := crypto.HashPassword("oldpassword") s.Require().NoError(err) // Reset user to original state before each test @@ -178,6 +186,47 @@ func (s *ProfileHandlerTestSuite) SetupTest() { s.mailer.Reset() } +func (s *ProfileHandlerTestSuite) TestSessions_IndexAndDestroyMatchChatwootContract() { + now := time.Now().UTC() + current := &model.UserSession{UserID: s.userID, ClientID: "current-client", BrowserName: "Chrome", PlatformName: "Linux", LastActivityAt: &now} + other := &model.UserSession{UserID: s.userID, ClientID: "other-client", BrowserName: "Firefox", PlatformName: "Windows", LastActivityAt: &now} + s.Require().NoError(s.db.Create(current).Error) + s.Require().NoError(s.db.Create(other).Error) + s.Require().NoError(s.refreshStore.StoreForClient(context.Background(), s.userID, current.ClientID, "current-refresh")) + s.Require().NoError(s.refreshStore.StoreForClient(context.Background(), s.userID, other.ClientID, "other-refresh")) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/profile/sessions", nil) + req.Header.Set("client", "current-client") + w := httptest.NewRecorder() + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code, w.Body.String()) + var payload []map[string]any + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload)) + s.Require().Len(payload, 2) + for _, session := range payload { + for _, key := range []string{"id", "browser_name", "browser_version", "device_name", "platform_name", "platform_version", "ip_address", "city", "country", "country_code", "last_activity_at", "created_at", "current"} { + s.Contains(session, key) + } + } + + req = httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/v1/profile/sessions/%d", current.ID), nil) + req.Header.Set("client", "current-client") + w = httptest.NewRecorder() + s.router.ServeHTTP(w, req) + s.Equal(http.StatusUnprocessableEntity, w.Code) + req = httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/v1/profile/sessions/%d", other.ID), nil) + req.Header.Set("client", "current-client") + w = httptest.NewRecorder() + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code) + var count int64 + s.db.Model(&model.UserSession{}).Where("id = ?", other.ID).Count(&count) + s.Equal(int64(0), count) + active, err := s.refreshStore.HasClient(context.Background(), s.userID, other.ClientID) + s.Require().NoError(err) + s.False(active) +} + type fakeProfileConfirmationMailer struct { calls []service.ProfileConfirmationMailRequest } diff --git a/backend/internal/handler/api/v1/sla_policy_handler.go b/backend/internal/handler/api/v1/sla_policy_handler.go index 4bd4f28a..947e91a3 100644 --- a/backend/internal/handler/api/v1/sla_policy_handler.go +++ b/backend/internal/handler/api/v1/sla_policy_handler.go @@ -348,9 +348,19 @@ func serializeAppliedSlaReportApplied(applied *model.AppliedSLA) gin.H { "sla_next_response_time_threshold": applied.SlaPolicy.NextResponseTimeThreshold, "sla_only_during_business_hours": applied.SlaPolicy.OnlyDuringBusinessHours, "sla_resolution_time_threshold": applied.SlaPolicy.ResolutionTimeThreshold, + "sla_frt_due_at": unixTimePointer(applied.FRTTargetAt), + "sla_nrt_due_at": unixTimePointer(applied.NRTTargetAt), + "sla_rt_due_at": unixTimePointer(applied.RTTargetAt), } } +func unixTimePointer(value *time.Time) any { + if value == nil { + return nil + } + return value.Unix() +} + func serializeAppliedSlaReportConversation(ctx context.Context, db *gorm.DB, conversationID uint) gin.H { conversation := findAppliedSlaConversation(ctx, db, conversationID) if conversation == nil { diff --git a/backend/internal/handler/api/v1/team_handler.go b/backend/internal/handler/api/v1/team_handler.go index 0d0b32a3..f30b49d7 100644 --- a/backend/internal/handler/api/v1/team_handler.go +++ b/backend/internal/handler/api/v1/team_handler.go @@ -298,6 +298,8 @@ func serializeTeamForAccount(c *gin.Context, svc *service.TeamService, team *mod "name": team.Name, "description": team.Description, "allow_auto_assign": team.AllowAutoAssignment, + "icon": team.Icon, + "icon_color": team.IconColor, "account_id": team.AccountID, "is_member": false, } diff --git a/backend/internal/handler/api/v1/whatsapp_call_handler.go b/backend/internal/handler/api/v1/whatsapp_call_handler.go index b367a9bd..f9438f46 100644 --- a/backend/internal/handler/api/v1/whatsapp_call_handler.go +++ b/backend/internal/handler/api/v1/whatsapp_call_handler.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "net/http" + "strconv" "strings" "time" @@ -24,6 +25,40 @@ func NewWhatsAppCallHandler(svc *service.WhatsAppCallService) *WhatsAppCallHandl return &WhatsAppCallHandler{svc: svc} } +// Index lists account calls with the exact dashboard pagination envelope. +// GET /api/v1/accounts/:account_id/calls +func (h *WhatsAppCallHandler) Index(c *gin.Context) { + accountID := parseAccountIDParam(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + inboxID, _ := strconv.ParseUint(c.Query("inbox_id"), 10, 64) + agentID, _ := strconv.ParseUint(c.Query("agent_id"), 10, 64) + role := c.GetString("role") + filter := service.AccountCallListFilter{Page: page, UserID: getUserID(c), CustomRoleID: getCustomRoleID(c), AccountWide: role == "administrator" || role == "super_admin", Status: c.Query("status"), Direction: c.Query("direction"), InboxID: uint(inboxID), AgentID: uint(agentID)} + if c.Query("since") != "" && c.Query("until") != "" { + since, errSince := parseChatwootReportTime(c.Query("since")) + until, errUntil := parseChatwootReportTime(c.Query("until")) + if errSince != nil || errUntil != nil { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrBadRequest, "invalid date range") + return + } + filter.Since, filter.Until = &since, &until + } + result, err := h.svc.ListAccountCalls(c.Request.Context(), accountID, filter) + if err != nil { + handleServiceError(c, err) + return + } + payload := make([]gin.H, 0, len(result.Calls)) + for i := range result.Calls { + payload = append(payload, serializeCallIndexItem(&result.Calls[i])) + } + c.JSON(http.StatusOK, gin.H{"meta": gin.H{"count": result.Count, "current_page": result.Page, "total_pages": result.TotalPages}, "payload": payload}) +} + // Show returns a Chatwoot WhatsApp call payload. // GET /api/v1/accounts/:account_id/whatsapp_calls/:id func (h *WhatsAppCallHandler) Show(c *gin.Context) { @@ -324,6 +359,28 @@ func serializeWhatsAppAccountCall(call *model.Call) gin.H { } } +func serializeCallIndexItem(call *model.Call) gin.H { + item := gin.H{ + "id": call.ID, "call_id": call.ProviderCallID, "provider": call.Provider, + "status": displayWhatsAppStatus(call.Status), "direction": displayWhatsAppDirection(call.Direction), + "duration_seconds": call.Duration, "end_reason": call.EndReason, "created_at": call.CreatedAt.Unix(), + "message_id": call.MessageID, "recording_url": call.RecordingURL, "transcript": call.Transcript, + "conversation": gin.H{"id": call.ConversationID, "display_id": call.Conversation.DisplayID}, + "inbox": gin.H{"id": call.InboxID, "name": call.Inbox.Name}, + "contact": gin.H{"id": call.ContactID, "name": call.Contact.Name, "phone_number": call.Contact.PhoneNumber, "avatar": call.Contact.AvatarURL}, + "agent": nil, + } + if call.StartedAt != nil { + item["started_at"] = call.StartedAt.Unix() + } else { + item["started_at"] = nil + } + if call.AcceptedByAgentID != nil && call.AcceptedByAgent.ID != 0 { + item["agent"] = gin.H{"id": call.AcceptedByAgent.ID, "name": call.AcceptedByAgent.Name, "avatar": call.AcceptedByAgent.AvatarURL} + } + return item +} + func displayWhatsAppStatus(status string) string { return strings.ReplaceAll(status, "_", "-") } diff --git a/backend/internal/handler/api/v1/whatsapp_call_handler_test.go b/backend/internal/handler/api/v1/whatsapp_call_handler_test.go index 92ec849c..d0df07a3 100644 --- a/backend/internal/handler/api/v1/whatsapp_call_handler_test.go +++ b/backend/internal/handler/api/v1/whatsapp_call_handler_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "mime/multipart" "net/http" "net/http/httptest" @@ -48,7 +49,7 @@ func setupWhatsAppCallHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB, *model.A gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open(":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.Call{}, &model.Message{}, &model.Attachment{}, &channelmodel.ChannelWhatsApp{})) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.User{}, &model.Inbox{}, &model.InboxMember{}, &model.CustomRole{}, &model.Contact{}, &model.Conversation{}, &model.Call{}, &model.Message{}, &model.Attachment{}, &channelmodel.ChannelWhatsApp{})) account := &model.Account{Name: "Voice Account", Status: "active"} require.NoError(t, db.Create(account).Error) @@ -68,8 +69,10 @@ func setupWhatsAppCallHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB, *model.A router := gin.New() router.Use(func(c *gin.Context) { c.Set("user_id", uint(7)) + c.Set("role", "administrator") c.Next() }) + router.GET("/api/v1/accounts/:account_id/calls", handler.Index) router.GET("/api/v1/accounts/:account_id/whatsapp_calls/:id", handler.Show) router.POST("/api/v1/accounts/:account_id/whatsapp_calls/initiate", handler.Initiate) router.POST("/api/v1/accounts/:account_id/whatsapp_calls/:id/accept", handler.Accept) @@ -79,6 +82,70 @@ func setupWhatsAppCallHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB, *model.A return router, db, account, conversation } +func TestWhatsAppCallHandler_IndexMatchesChatwootCallsEnvelope(t *testing.T) { + router, db, account, conversation := setupWhatsAppCallHandlerTest(t) + call := &model.Call{AccountID: account.ID, InboxID: conversation.InboxID, ConversationID: conversation.ID, ContactID: conversation.ContactID, Provider: "whatsapp", Direction: "incoming", ProviderCallID: "wacid_index", Status: "in_progress", Duration: 12, CallerType: "Contact", CallerID: conversation.ContactID, CallDirection: "inbound", Transcript: "hello"} + require.NoError(t, db.Create(call).Error) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/calls?status=in-progress&direction=inbound", account.ID), nil) + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + payload := whatsappDecodeMap(t, w.Body.Bytes()) + meta := payload["meta"].(map[string]any) + require.Equal(t, float64(1), meta["count"]) + items := payload["payload"].([]any) + require.Len(t, items, 1) + item := items[0].(map[string]any) + for _, key := range []string{"id", "call_id", "provider", "status", "direction", "duration_seconds", "conversation", "inbox", "agent", "contact", "transcript"} { + require.Contains(t, item, key) + } + require.Equal(t, "in-progress", item["status"]) + require.Equal(t, "inbound", item["direction"]) +} + +func TestWhatsAppCallService_IndexScopesAgentsToHandledCalls(t *testing.T) { + _, db, account, conversation := setupWhatsAppCallHandlerTest(t) + agent := &model.User{Name: "Agent", Email: "call-agent@example.com"} + require.NoError(t, db.Create(agent).Error) + require.NoError(t, db.Create(&model.InboxMember{InboxID: conversation.InboxID, UserID: agent.ID}).Error) + owned := &model.Call{AccountID: account.ID, InboxID: conversation.InboxID, ConversationID: conversation.ID, ContactID: conversation.ContactID, AcceptedByAgentID: &agent.ID, Provider: "whatsapp", Direction: "incoming", ProviderCallID: "owned", Status: "completed", CallerType: "Contact", CallerID: conversation.ContactID, CallDirection: "inbound"} + other := &model.Call{AccountID: account.ID, InboxID: conversation.InboxID, ConversationID: conversation.ID, ContactID: conversation.ContactID, Provider: "whatsapp", Direction: "incoming", ProviderCallID: "other", Status: "completed", CallerType: "Contact", CallerID: conversation.ContactID, CallDirection: "inbound"} + require.NoError(t, db.Create(owned).Error) + require.NoError(t, db.Create(other).Error) + svc := service.NewWhatsAppCallService(repository.NewWhatsAppCallRepo(db), fakeHandlerWhatsAppCallProvider{}) + result, err := svc.ListAccountCalls(context.Background(), account.ID, service.AccountCallListFilter{UserID: agent.ID}) + require.NoError(t, err) + require.Equal(t, int64(1), result.Count) + require.Len(t, result.Calls, 1) + require.Equal(t, owned.ID, result.Calls[0].ID) +} + +func TestWhatsAppCallService_IndexMatchesConversationAndReportManagerVisibility(t *testing.T) { + _, db, account, conversation := setupWhatsAppCallHandlerTest(t) + agent := &model.User{Name: "Agent", Email: "visibility@example.com"} + require.NoError(t, db.Create(agent).Error) + call := &model.Call{AccountID: account.ID, InboxID: conversation.InboxID, ConversationID: conversation.ID, ContactID: conversation.ContactID, AcceptedByAgentID: &agent.ID, Provider: "whatsapp", ProviderCallID: "visibility", Status: "completed"} + require.NoError(t, db.Create(call).Error) + svc := service.NewWhatsAppCallService(repository.NewWhatsAppCallRepo(db), fakeHandlerWhatsAppCallProvider{}) + + result, err := svc.ListAccountCalls(context.Background(), account.ID, service.AccountCallListFilter{UserID: agent.ID}) + require.NoError(t, err) + require.Zero(t, result.Count) + + require.NoError(t, db.Create(&model.InboxMember{InboxID: conversation.InboxID, UserID: agent.ID}).Error) + result, err = svc.ListAccountCalls(context.Background(), account.ID, service.AccountCallListFilter{UserID: agent.ID}) + require.NoError(t, err) + require.Equal(t, int64(1), result.Count) + + role := &model.CustomRole{AccountID: account.ID, Name: "Report manager"} + require.NoError(t, role.SetPermissionKeys([]model.PermissionDimension{model.DimensionReportManage})) + require.NoError(t, db.Create(role).Error) + require.NoError(t, db.Where("inbox_id = ? AND user_id = ?", conversation.InboxID, agent.ID).Delete(&model.InboxMember{}).Error) + result, err = svc.ListAccountCalls(context.Background(), account.ID, service.AccountCallListFilter{UserID: agent.ID, CustomRoleID: role.ID}) + require.NoError(t, err) + require.Equal(t, int64(1), result.Count) +} + func TestWhatsAppCallHandler_AccountRoutesMatchFrontendAPI(t *testing.T) { router, db, account, conversation := setupWhatsAppCallHandlerTest(t) diff --git a/backend/internal/middleware/account_scope.go b/backend/internal/middleware/account_scope.go index 6c762382..26b5b113 100644 --- a/backend/internal/middleware/account_scope.go +++ b/backend/internal/middleware/account_scope.go @@ -118,10 +118,15 @@ func AccountScopeWithService(lookup RBACLookup) gin.HandlerFunc { return } - accountID, ok := resolveScopedAccountID(c) - if !ok { + accountID := getAccountID(c) + routeID, hasRouteID, routeOK := routeAccountID(c) + if !routeOK { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } + if hasRouteID { + accountID = routeID + } if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "Account ID required — provide via X-Account-ID header or JWT claims") @@ -131,7 +136,7 @@ func AccountScopeWithService(lookup RBACLookup) gin.HandlerFunc { c.Set("account_id", accountID) // Look up AccountUser to get role and CustomRoleID - accountUser, err := lookup.GetAccountUserRole(userID.(uint), accountID) + role, customRoleID, err := lookup.GetAccountUserRole(userID.(uint), accountID) if err != nil { response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, "User does not belong to this account") @@ -140,8 +145,8 @@ func AccountScopeWithService(lookup RBACLookup) gin.HandlerFunc { // Build permissions matrix based on role permissions := auth.PermissionMatrixMap{} - if accountUser.CustomRoleID > 0 && accountUser.Role != "administrator" { - pm, err := lookup.GetCustomRolePermissions(accountUser.CustomRoleID) + if customRoleID > 0 && role != "administrator" { + pm, err := lookup.GetCustomRolePermissions(customRoleID) if err != nil { // Fallback to agent defaults if custom role not found permissions = auth.AgentDefaultPermissions @@ -150,16 +155,18 @@ func AccountScopeWithService(lookup RBACLookup) gin.HandlerFunc { } } - effectiveRole := accountUser.Role - if accountUser.CustomRoleID > 0 && effectiveRole != "administrator" { + effectiveRole := role + if customRoleID > 0 && effectiveRole != "administrator" { effectiveRole = "custom_role" } + c.Set("role", effectiveRole) + c.Set("custom_role_id", customRoleID) policyCtx := auth.NewPolicyContext( userID.(uint), accountID, effectiveRole, - accountUser.CustomRoleID, + customRoleID, permissions, ) @@ -243,16 +250,6 @@ func getAccountID(c *gin.Context) uint { // RBACLookup is the interface that the RBAC service must implement // for use with AccountScopeWithService middleware. type RBACLookup interface { - GetAccountUserRole(userID, accountID uint) (*AccountUserRole, error) + GetAccountUserRole(userID, accountID uint) (role string, customRoleID uint, err error) GetCustomRolePermissions(customRoleID uint) (auth.PermissionMatrixMap, error) } - -// AccountUserRole holds the essential AccountUser data needed by middleware. -// This is a lightweight DTO that the service populates from the model.AccountUser. -type AccountUserRole struct { - UserID uint - AccountID uint - Role string - CustomRoleID uint - Availability string -} diff --git a/backend/internal/middleware/account_scope_test.go b/backend/internal/middleware/account_scope_test.go index 057ad06f..7be5e7e4 100644 --- a/backend/internal/middleware/account_scope_test.go +++ b/backend/internal/middleware/account_scope_test.go @@ -1,6 +1,7 @@ package middleware import ( + "errors" "net/http" "net/http/httptest" "testing" @@ -13,6 +14,19 @@ import ( "github.com/stretchr/testify/require" ) +type accountScopeLookup struct{ accountID uint } + +func (l accountScopeLookup) GetAccountUserRole(_ uint, accountID uint) (string, uint, error) { + if accountID != l.accountID { + return "", 0, errors.New("membership not found") + } + return "administrator", 0, nil +} + +func (accountScopeLookup) GetCustomRolePermissions(uint) (auth.PermissionMatrixMap, error) { + return nil, nil +} + func TestAccountScope_NoUserID(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() @@ -170,3 +184,26 @@ func TestAccountScope_UsesAuthMiddlewareRoleForAdministratorRoute(t *testing.T) assert.Equal(t, http.StatusOK, w.Code, w.Body.String()) } + +func TestAccountScopeWithService_AllowsVerifiedAccountSwitch(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(1)) + c.Set("role", "agent") + c.Next() + }) + r.Use(AccountScopeWithService(accountScopeLookup{accountID: 2})) + r.GET("/api/v1/accounts/:account_id", RoleCheck("administrator"), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"account_id": c.GetUint("account_id")}) + }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/accounts/2", nil)) + assert.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + w = httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/accounts/3", nil)) + assert.Equal(t, http.StatusForbidden, w.Code, w.Body.String()) +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index 8a04ba03..0ef911cf 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -11,6 +11,8 @@ import ( "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/config" + "github.com/gochat/gochat/internal/model" + "gorm.io/gorm" ) // AuthMiddleware validates JWT tokens on protected routes. @@ -30,6 +32,13 @@ func AuthMiddleware(cfg *config.JWTConfig) gin.HandlerFunc { // AuthMiddlewareWithService accepts a pre-built JWTService (for DI in tests/bootstrap). func AuthMiddlewareWithService(jwtSvc *auth.JWTService) gin.HandlerFunc { + return AuthMiddlewareWithServiceAndDB(jwtSvc, nil) +} + +// AuthMiddlewareWithServiceAndDB additionally enforces Chatwoot client-session +// revocation for tokens carrying a client_id claim. Legacy/API JWTs without a +// client id retain the existing stateless behavior. +func AuthMiddlewareWithServiceAndDB(jwtSvc *auth.JWTService, db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { authHeader := c.GetHeader("Authorization") chatwootAccessToken := strings.TrimSpace(c.GetHeader("access-token")) @@ -47,6 +56,17 @@ func AuthMiddlewareWithService(jwtSvc *auth.JWTService) gin.HandlerFunc { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) return } + if claims.ClientID != "" && db != nil { + var session model.UserSession + if err := db.WithContext(c.Request.Context()).Where("user_id = ? AND client_id = ?", claims.UserID, claims.ClientID).First(&session).Error; err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session revoked"}) + return + } + if session.LastActivityAt == nil || session.LastActivityAt.Before(time.Now().Add(-5*time.Minute)) { + now := time.Now().UTC() + _ = db.WithContext(c.Request.Context()).Model(&session).Updates(map[string]any{"last_activity_at": now, "updated_at": now}).Error + } + } // Set typed claims values in Gin context for downstream middleware/handlers. // PolicyMiddleware reads these to build PolicyContext. diff --git a/backend/internal/middleware/auth_test.go b/backend/internal/middleware/auth_test.go index ec845834..2cf56d01 100644 --- a/backend/internal/middleware/auth_test.go +++ b/backend/internal/middleware/auth_test.go @@ -8,6 +8,8 @@ import ( "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" + "gorm.io/driver/sqlite" + "gorm.io/gorm" "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/config" @@ -118,6 +120,34 @@ func TestAuthMiddleware_ChatwootAccessTokenHeader(t *testing.T) { assert.Equal(t, 200, w.Code) } +func TestAuthMiddleware_RejectsRevokedChatwootSession(t *testing.T) { + ginsvc := gin.New() + cfg := makeJWTConfig() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{}) + assert.NoError(t, err) + assert.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{})) + jwtService := auth.NewJWTService(cfg) + user := &model.User{Base: model.Base{ID: 1}, Provider: "email", Email: "session@example.com"} + assert.NoError(t, db.Create(user).Error) + pair, err := jwtService.GenerateTokenPairForClient(user, 2, "agent", "client-1") + assert.NoError(t, err) + ginsvc.Use(AuthMiddlewareWithServiceAndDB(jwtService, db)) + ginsvc.GET("/test", func(c *gin.Context) { c.Status(http.StatusOK) }) + + request := func() int { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("access-token", pair.AccessToken) + ginsvc.ServeHTTP(w, req) + return w.Code + } + assert.Equal(t, http.StatusUnauthorized, request()) + assert.NoError(t, db.Create(&model.UserSession{UserID: user.ID, ClientID: "client-1"}).Error) + assert.Equal(t, http.StatusOK, request()) + assert.NoError(t, db.Where("user_id = ? AND client_id = ?", user.ID, "client-1").Delete(&model.UserSession{}).Error) + assert.Equal(t, http.StatusUnauthorized, request()) +} + func TestAuthMiddleware_AllowsPlatformAdminThroughSuperAdminGuard(t *testing.T) { gin.SetMode(gin.TestMode) cfg := makeJWTConfig() diff --git a/backend/internal/middleware/csrf.go b/backend/internal/middleware/csrf.go index fedfe56b..2b724cb5 100644 --- a/backend/internal/middleware/csrf.go +++ b/backend/internal/middleware/csrf.go @@ -17,18 +17,18 @@ import ( // adapted for API-first architecture (no server-side session required). type CSRFConfig struct { Enabled bool `mapstructure:"enabled"` - Secret string `mapstructure:"secret"` // 32-byte hex secret for HMAC token generation - CookieName string `mapstructure:"cookie_name"` // default: "_gochat_csrf" - HeaderName string `mapstructure:"header_name"` // default: X-CSRF-Token - TokenLength int `mapstructure:"token_length"` // default: 32 bytes - SafeMethods []string `mapstructure:"safe_methods"` // default: GET, HEAD, OPTIONS - SkipPaths []string `mapstructure:"skip_paths"` // paths that skip CSRF validation entirely (e.g., /api/v1/auth/login) - CookieSecure bool `mapstructure:"cookie_secure"` // set Secure flag (prod: true) + Secret string `mapstructure:"secret"` // 32-byte hex secret for HMAC token generation + CookieName string `mapstructure:"cookie_name"` // default: "_gochat_csrf" + HeaderName string `mapstructure:"header_name"` // default: X-CSRF-Token + TokenLength int `mapstructure:"token_length"` // default: 32 bytes + SafeMethods []string `mapstructure:"safe_methods"` // default: GET, HEAD, OPTIONS + SkipPaths []string `mapstructure:"skip_paths"` // paths that skip CSRF validation entirely (e.g., /api/v1/auth/login) + CookieSecure bool `mapstructure:"cookie_secure"` // set Secure flag (prod: true) CookieHTTPOnly bool `mapstructure:"cookie_http_only"` // set HttpOnly flag (default: false — JS must read for double-submit) CookieSameSite string `mapstructure:"cookie_same_site"` // Strict, Lax, or None (default: Strict) - CookieDomain string `mapstructure:"cookie_domain"` // optional domain restriction - CookiePath string `mapstructure:"cookie_path"` // default: / - ExpirySeconds int `mapstructure:"expiry_seconds"` // token rotation period (default: 3600) + CookieDomain string `mapstructure:"cookie_domain"` // optional domain restriction + CookiePath string `mapstructure:"cookie_path"` // default: / + ExpirySeconds int `mapstructure:"expiry_seconds"` // token rotation period (default: 3600) } // DefaultCSRFConfig returns a secure-by-default CSRF configuration. @@ -115,6 +115,13 @@ func CSRF(cfg CSRFConfig) gin.HandlerFunc { } return func(c *gin.Context) { + // CSRF cannot forge custom token-auth headers; account APIs use these + // instead of browser cookies, including /enterprise/api routes. + if c.GetHeader("Authorization") != "" || c.GetHeader("access-token") != "" { + c.Next() + return + } + // Skip CSRF validation for configured paths (public auth endpoints, health checks, webhooks) path := c.Request.URL.Path for _, skip := range cfg.SkipPaths { @@ -212,4 +219,4 @@ func isSafeMethod(method string, safeMethods []string) bool { } } return false -} \ No newline at end of file +} diff --git a/backend/internal/middleware/csrf_test.go b/backend/internal/middleware/csrf_test.go index be9ca87c..bc52ebbd 100644 --- a/backend/internal/middleware/csrf_test.go +++ b/backend/internal/middleware/csrf_test.go @@ -54,3 +54,19 @@ func TestCSRFSkipsTokenAuthenticatedAPIRoutes(t *testing.T) { require.Equal(t, http.StatusOK, recorder.Code) } + +func TestCSRFSkipsAccessTokenAuthenticatedEnterpriseRoutes(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"})) + router.POST("/enterprise/api/v1/accounts/:account_id/select_billing_currency", func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/enterprise/api/v1/accounts/1/select_billing_currency", nil) + request.Header.Set("access-token", "signed-token") + router.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusNoContent, recorder.Code) +} diff --git a/backend/internal/model/assignment_policy.go b/backend/internal/model/assignment_policy.go index c148b419..eaf9ab03 100644 --- a/backend/internal/model/assignment_policy.go +++ b/backend/internal/model/assignment_policy.go @@ -7,19 +7,20 @@ import ( // AssignmentPolicy represents a conversation assignment policy for an account. // Reference: Chatwoot app/models/assignment_policy.rb + M5 spec type AssignmentPolicy struct { - ID uint `gorm:"primaryKey" json:"id"` - AccountID uint `gorm:"not null;index;uniqueIndex:idx_account_policy_name" json:"account_id"` - Name string `gorm:"size:255;not null;uniqueIndex:idx_account_policy_name" json:"name"` - Description string `gorm:"type:text" json:"description"` - AssignmentOrder int `gorm:"default:0" json:"assignment_order"` // 0=round_robin, 1=balanced (enterprise) - ConversationPriority int `gorm:"default:0" json:"conversation_priority"` // 0=earliest_created, 1=longest_waiting - FairDistributionLimit int `gorm:"default:100" json:"fair_distribution_limit"` - FairDistributionWindow int `gorm:"default:3600" json:"fair_distribution_window"` - Enabled bool `gorm:"default:true" json:"enabled"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + ID uint `gorm:"primaryKey" json:"id"` + AccountID uint `gorm:"not null;index;uniqueIndex:idx_account_policy_name" json:"account_id"` + Name string `gorm:"size:255;not null;uniqueIndex:idx_account_policy_name" json:"name"` + Description string `gorm:"type:text" json:"description"` + AssignmentOrder int `gorm:"default:0" json:"assignment_order"` // 0=round_robin, 1=balanced (enterprise) + ConversationPriority int `gorm:"default:0" json:"conversation_priority"` // 0=earliest_created, 1=longest_waiting + FairDistributionLimit int `gorm:"default:100" json:"fair_distribution_limit"` + FairDistributionWindow int `gorm:"default:3600" json:"fair_distribution_window"` + ExcludeOlderThanHours *int `gorm:"default:168" json:"exclude_older_than_hours"` + Enabled bool `gorm:"default:true" json:"enabled"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"` + Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"` InboxAssignmentPolicies []InboxAssignmentPolicy `gorm:"foreignKey:AssignmentPolicyID" json:"inbox_assignment_policies,omitempty"` } @@ -29,14 +30,14 @@ func (AssignmentPolicy) TableName() string { return "assignment_policies" } // Each Inbox can only be associated with one AssignmentPolicy. // Reference: Chatwoot app/models/inbox_assignment_policy.rb + M5 spec type InboxAssignmentPolicy struct { - ID uint `gorm:"primaryKey" json:"id"` - InboxID uint `gorm:"not null;uniqueIndex" json:"inbox_id"` - AssignmentPolicyID uint `gorm:"not null;index" json:"assignment_policy_id"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + ID uint `gorm:"primaryKey" json:"id"` + InboxID uint `gorm:"not null;uniqueIndex" json:"inbox_id"` + AssignmentPolicyID uint `gorm:"not null;index" json:"assignment_policy_id"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - Inbox Inbox `gorm:"foreignKey:InboxID" json:"inbox,omitempty"` - AssignmentPolicy AssignmentPolicy `gorm:"foreignKey:AssignmentPolicyID" json:"assignment_policy,omitempty"` + Inbox Inbox `gorm:"foreignKey:InboxID" json:"inbox,omitempty"` + AssignmentPolicy AssignmentPolicy `gorm:"foreignKey:AssignmentPolicyID" json:"assignment_policy,omitempty"` } -func (InboxAssignmentPolicy) TableName() string { return "inbox_assignment_policies" } \ No newline at end of file +func (InboxAssignmentPolicy) TableName() string { return "inbox_assignment_policies" } diff --git a/backend/internal/model/call.go b/backend/internal/model/call.go index 68023c0b..5278628d 100644 --- a/backend/internal/model/call.go +++ b/backend/internal/model/call.go @@ -29,6 +29,7 @@ type Call struct { EndReason string `gorm:"column:end_reason;size:255" json:"end_reason,omitempty"` CallDirection string `gorm:"size:50;not null" json:"call_direction"` // inbound/outbound RecordingURL string `gorm:"size:512" json:"recording_url"` + Transcript string `gorm:"type:text" json:"transcript"` AdditionalAttributes json.RawMessage `gorm:"type:jsonb" json:"additional_attributes"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` diff --git a/backend/internal/model/captain_message_report.go b/backend/internal/model/captain_message_report.go new file mode 100644 index 00000000..63351aa0 --- /dev/null +++ b/backend/internal/model/captain_message_report.go @@ -0,0 +1,18 @@ +package model + +import "time" + +// CaptainMessageReport records a user's report of an assistant-authored message. +type CaptainMessageReport struct { + ID uint `gorm:"primaryKey" json:"id"` + AccountID uint `gorm:"not null;index" json:"account_id"` + ConversationID uint `gorm:"not null;index" json:"conversation_id"` + MessageID uint `gorm:"not null;index" json:"message_id"` + UserID uint `gorm:"not null;index" json:"user_id"` + ReportReason string `gorm:"size:255;not null" json:"report_reason"` + Description string `gorm:"type:text" json:"description"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +func (CaptainMessageReport) TableName() string { return "captain_message_reports" } diff --git a/backend/internal/model/category.go b/backend/internal/model/category.go index e54eed88..37ee63b5 100644 --- a/backend/internal/model/category.go +++ b/backend/internal/model/category.go @@ -8,24 +8,25 @@ import ( // Reference: Chatwoot Category model + P2B M9 spec type Category struct { Base - AccountID uint `gorm:"index;not null" json:"account_id"` - PortalID uint `gorm:"index;not null" json:"portal_id"` - Name string `gorm:"size:255;not null" json:"name"` - Slug string `gorm:"size:255;not null;index" json:"slug"` - Description string `gorm:"type:text" json:"description"` - Icon string `gorm:"size:255;default:''" json:"icon"` - Position int `gorm:"default:0" json:"position"` - Locale string `gorm:"size:10;default:'en';not null" json:"locale"` - ParentID *uint `gorm:"index" json:"parent_id,omitempty"` - AssociatedCategoryID *uint `gorm:"index" json:"associated_category_id,omitempty"` // cross-locale link - CustomAttributes json.RawMessage `gorm:"type:jsonb;serializer:json" json:"custom_attributes"` + AccountID uint `gorm:"index;not null" json:"account_id"` + PortalID uint `gorm:"index;not null" json:"portal_id"` + Name string `gorm:"size:255;not null" json:"name"` + Slug string `gorm:"size:255;not null;index" json:"slug"` + Description string `gorm:"type:text" json:"description"` + Icon string `gorm:"size:255;default:''" json:"icon"` + IconColor string `gorm:"size:255;default:''" json:"icon_color"` + Position int `gorm:"default:0" json:"position"` + Locale string `gorm:"size:10;default:'en';not null" json:"locale"` + ParentID *uint `gorm:"index" json:"parent_id,omitempty"` + AssociatedCategoryID *uint `gorm:"index" json:"associated_category_id,omitempty"` // cross-locale link + CustomAttributes json.RawMessage `gorm:"type:jsonb;serializer:json" json:"custom_attributes"` - Portal Portal `gorm:"foreignKey:PortalID" json:"portal,omitempty"` - Parent *Category `gorm:"foreignKey:ParentID" json:"parent,omitempty"` - AssociatedCategory *Category `gorm:"foreignKey:AssociatedCategoryID" json:"associated_category,omitempty"` - Articles []Article `gorm:"foreignKey:CategoryID" json:"articles,omitempty"` - Folders []Folder `gorm:"foreignKey:CategoryID" json:"folders,omitempty"` - RelatedCategories []RelatedCategory `gorm:"foreignKey:CategoryID" json:"related_categories,omitempty"` + Portal Portal `gorm:"foreignKey:PortalID" json:"portal,omitempty"` + Parent *Category `gorm:"foreignKey:ParentID" json:"parent,omitempty"` + AssociatedCategory *Category `gorm:"foreignKey:AssociatedCategoryID" json:"associated_category,omitempty"` + Articles []Article `gorm:"foreignKey:CategoryID" json:"articles,omitempty"` + Folders []Folder `gorm:"foreignKey:CategoryID" json:"folders,omitempty"` + RelatedCategories []RelatedCategory `gorm:"foreignKey:CategoryID" json:"related_categories,omitempty"` } -func (Category) TableName() string { return "categories" } \ No newline at end of file +func (Category) TableName() string { return "categories" } diff --git a/backend/internal/model/team.go b/backend/internal/model/team.go index f753ba34..3e7e1ef2 100644 --- a/backend/internal/model/team.go +++ b/backend/internal/model/team.go @@ -4,13 +4,15 @@ package model // Reference: Chatwoot Team model + P2B M5 spec type Team struct { Base - AccountID uint `gorm:"not null;index" json:"account_id"` - Name string `gorm:"size:255;not null" json:"name"` - Description string `gorm:"type:text" json:"description"` - AllowAutoAssignment bool `gorm:"default:true" json:"allow_auto_assignment"` + AccountID uint `gorm:"not null;index" json:"account_id"` + Name string `gorm:"size:255;not null" json:"name"` + Description string `gorm:"type:text" json:"description"` + AllowAutoAssignment bool `gorm:"default:true" json:"allow_auto_assignment"` + Icon string `gorm:"size:255;default:''" json:"icon"` + IconColor string `gorm:"size:255;default:''" json:"icon_color"` - Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"` - Members []TeamMember `gorm:"foreignKey:TeamID" json:"members,omitempty"` + Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"` + Members []TeamMember `gorm:"foreignKey:TeamID" json:"members,omitempty"` } -func (Team) TableName() string { return "teams" } \ No newline at end of file +func (Team) TableName() string { return "teams" } diff --git a/backend/internal/model/user_session.go b/backend/internal/model/user_session.go new file mode 100644 index 00000000..02714e35 --- /dev/null +++ b/backend/internal/model/user_session.go @@ -0,0 +1,26 @@ +package model + +import "time" + +// UserSession tracks the DeviseTokenAuth client session displayed by Chatwoot's +// profile Active Sessions page. +type UserSession struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null;index;uniqueIndex:idx_user_session_client" json:"user_id"` + ClientID string `gorm:"size:255;not null;uniqueIndex:idx_user_session_client" json:"client_id"` + IPAddress string `gorm:"size:255" json:"ip_address"` + UserAgent string `gorm:"type:text" json:"-"` + BrowserName string `gorm:"size:255" json:"browser_name"` + BrowserVersion string `gorm:"size:255" json:"browser_version"` + DeviceName string `gorm:"size:255" json:"device_name"` + PlatformName string `gorm:"size:255" json:"platform_name"` + PlatformVersion string `gorm:"size:255" json:"platform_version"` + City string `gorm:"size:255" json:"city"` + Country string `gorm:"size:255" json:"country"` + CountryCode string `gorm:"size:32" json:"country_code"` + LastActivityAt *time.Time `json:"last_activity_at"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +func (UserSession) TableName() string { return "user_sessions" } diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index e0b6a6cc..0f384f48 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -16,6 +16,13 @@ type AccountRepo struct { db *gorm.DB } +func (r *AccountRepo) DB() *gorm.DB { + if r == nil { + return nil + } + return r.db +} + // FindByUserAndID retrieves an account only when the user belongs to it. func (r *AccountRepo) FindByUserAndID(ctx context.Context, userID, accountID uint) (*model.Account, error) { var account model.Account diff --git a/backend/internal/repository/captain_assistant_repo.go b/backend/internal/repository/captain_assistant_repo.go index f911e012..66afd1ed 100644 --- a/backend/internal/repository/captain_assistant_repo.go +++ b/backend/internal/repository/captain_assistant_repo.go @@ -16,6 +16,8 @@ func NewCaptainAssistantRepo(db *gorm.DB) *CaptainAssistantRepo { return &CaptainAssistantRepo{db: db} } +func (r *CaptainAssistantRepo) DB() *gorm.DB { return r.db } + func (r *CaptainAssistantRepo) Create(ctx context.Context, assistant *model.CaptainAssistant) error { return r.db.WithContext(ctx).Create(assistant).Error } diff --git a/backend/internal/repository/conversation_repo.go b/backend/internal/repository/conversation_repo.go index ebda952c..544d448b 100644 --- a/backend/internal/repository/conversation_repo.go +++ b/backend/internal/repository/conversation_repo.go @@ -282,8 +282,21 @@ func (r *ConversationRepo) UpdateStatus(ctx context.Context, id uint, status mod // AssignAgent assigns a conversation to an agent. func (r *ConversationRepo) AssignAgent(ctx context.Context, id, assigneeID uint) error { + var value any + if assigneeID != 0 { + value = assigneeID + } return r.db.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", id). - Update("assignee_id", assigneeID).Error + Updates(map[string]any{"assignee_id": value, "assignee_agent_bot_id": nil}).Error +} + +func (r *ConversationRepo) AssignAgentBot(ctx context.Context, id, agentBotID uint) error { + var value any + if agentBotID != 0 { + value = agentBotID + } + return r.db.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", id). + Updates(map[string]any{"assignee_id": nil, "assignee_agent_bot_id": value}).Error } // ToggleStatus toggles conversation between open/resolved. diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index d6381e88..fcb931ca 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -14,6 +14,13 @@ type UserRepo struct { db *gorm.DB } +func (r *UserRepo) DB() *gorm.DB { + if r == nil { + return nil + } + return r.db +} + // NewUserRepo creates a new User repository. func NewUserRepo(db *gorm.DB) *UserRepo { return &UserRepo{db: db} @@ -139,4 +146,4 @@ func (r *UserRepo) Count(ctx context.Context) (int64, error) { var total int64 err := r.db.WithContext(ctx).Model(&model.User{}).Count(&total).Error return total, err -} \ No newline at end of file +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index cad4c6b6..84b9cae2 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -38,6 +38,7 @@ var startTime = time.Now() // Handlers holds all instantiated handler structs for route registration. // Passed from bootstrap to avoid global state and keep dependency wiring explicit. type Handlers struct { + RBAC middleware.RBACLookup Auth *v1.AuthHandler MFA *v1.MFAHandler SAML *v1.SAMLHandler @@ -242,29 +243,29 @@ func RegisterRoutes( v1.RegisterLDAPRoutes(engine.Group("/api/v1"), handlers.LDAP) // OIDC routes — mixed: public auth flow + admin config routes (OIDC flow is external) - v1.RegisterOIDCRoutes(engine.Group("/api/v1"), handlers.OIDC, middleware.AuthMiddleware(jwtCfg)) + v1.RegisterOIDCRoutes(engine.Group("/api/v1"), handlers.OIDC, middleware.AuthMiddlewareWithServiceAndDB(jwtService, db)) // MFA routes — require authentication for enable/verify/disable mfaPublic := engine.Group("/api/v1") - mfaPublic.Use(middleware.AuthMiddleware(jwtCfg)) + mfaPublic.Use(middleware.AuthMiddlewareWithServiceAndDB(jwtService, db)) v1.RegisterMFARoutes(mfaPublic, handlers.MFA) // API v1 routes — authenticated, account-scoped apiV1 := engine.Group("/api/v1") - apiV1.Use(middleware.AuthMiddleware(jwtCfg)) + apiV1.Use(middleware.AuthMiddlewareWithServiceAndDB(jwtService, db)) registerV1Routes(apiV1, handlers) // Enterprise API routes consumed by the reused Chatwoot dashboard. // Reference: Chatwoot routes.rb namespace :enterprise/:api/:v1. enterpriseV1 := engine.Group("/enterprise/api/v1") - enterpriseV1.Use(middleware.AuthMiddleware(jwtCfg)) + enterpriseV1.Use(middleware.AuthMiddlewareWithServiceAndDB(jwtService, db)) registerEnterpriseRoutes(enterpriseV1, handlers) // Platform API routes — super admin only (ref: Chatwoot namespace :platform_app) // Chatwoot uses a single /platform/api/v1 prefix with AccessTokenable concern in controller layer // for auth differentiation. We merge SuperAdmin and AccessToken routes into one group. platform := engine.Group("/platform/api/v1") - platform.Use(middleware.AuthMiddleware(jwtCfg)) + platform.Use(middleware.AuthMiddlewareWithServiceAndDB(jwtService, db)) // SuperAdmin-only routes use additional middleware; AccessToken routes are open to platform app tokens // Register both sets of routes in the same group (Gin does not allow duplicate prefix groups) registerPlatformRoutes(platform, handlers) @@ -347,7 +348,7 @@ func RegisterRoutes( // API v2 routes — authenticated, account-scoped report APIs. apiV2 := engine.Group("/api/v2") - apiV2.Use(middleware.AuthMiddleware(jwtCfg)) + apiV2.Use(middleware.AuthMiddlewareWithServiceAndDB(jwtService, db)) registerV2Routes(apiV2, handlers) // Webhook callback routes. @@ -611,6 +612,8 @@ func registerEnterpriseRoutes(g *gin.RouterGroup, h *Handlers) { accounts.GET("/:account_id/limits", h.EnterpriseAccount.Limits) accounts.POST("/:account_id/toggle_deletion", h.EnterpriseAccount.ToggleDeletion) accounts.POST("/:account_id/topup_checkout", h.EnterpriseAccount.TopupCheckout) + accounts.POST("/:account_id/select_billing_currency", h.EnterpriseAccount.SelectBillingCurrency) + accounts.GET("/:account_id/topup_options", h.EnterpriseAccount.TopupOptions) } // EnterpriseAccountAPI uses an empty resource with accountScoped=true. In a @@ -622,6 +625,8 @@ func registerEnterpriseRoutes(g *gin.RouterGroup, h *Handlers) { g.GET("/limits", h.EnterpriseAccount.Limits) g.POST("/toggle_deletion", h.EnterpriseAccount.ToggleDeletion) g.POST("/topup_checkout", h.EnterpriseAccount.TopupCheckout) + g.POST("/select_billing_currency", h.EnterpriseAccount.SelectBillingCurrency) + g.GET("/topup_options", h.EnterpriseAccount.TopupOptions) } // registerV1Routes maps all API v1 resource routes. @@ -638,6 +643,8 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { g.PUT("/profile/set_active_account", h.Profile.SetActiveAccount) g.POST("/profile/resend_confirmation", h.Profile.ResendConfirmation) g.POST("/profile/reset_access_token", h.Profile.ResetAccessToken) + g.GET("/profile/sessions", h.Profile.ListSessions) + g.DELETE("/profile/sessions/:id", h.Profile.RevokeSession) // MFA routes under profile scope (Chatwoot: scope module: 'profile' do resource :mfa) // GET /profile/mfa — show status, POST /profile/mfa — create (enable), DELETE /profile/mfa — destroy (disable) @@ -698,7 +705,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { g.POST("/accounts", h.Account.Create) accounts := g.Group("/accounts") - accounts.Use(middleware.AccountScope()) + accounts.Use(accountScopeMiddleware(h)) { accounts.GET("/", h.Account.List) accounts.POST("/", h.Account.Create) @@ -708,6 +715,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { accounts.DELETE("/:account_id", h.Account.Delete) // Account onboarding update (ref: Chatwoot resource :onboarding, only: [:update]) accounts.PATCH("/:account_id/onboarding", middleware.RoleCheck("administrator"), h.Account.UpdateOnboarding) + accounts.GET("/:account_id/onboarding/help_center_generation", middleware.RoleCheck("administrator"), h.Account.HelpCenterGeneration) // Account settings (ref: Chatwoot accounts#update settings subset) accounts.PUT("/:account_id/settings", h.Account.UpdateSettings) @@ -752,6 +760,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { accounts.POST("/:account_id/bulk_actions", h.BulkAction.Create) // Account WhatsApp calls (ref: Chatwoot enterprise whatsapp_calls_controller.rb) + accounts.GET("/:account_id/calls", h.WhatsAppCall.Index) accounts.GET("/:account_id/whatsapp_calls/:id", h.WhatsAppCall.Show) accounts.POST("/:account_id/whatsapp_calls/initiate", h.WhatsAppCall.Initiate) accounts.POST("/:account_id/whatsapp_calls/:id/accept", h.WhatsAppCall.Accept) @@ -796,6 +805,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { inboxes.POST("/:inbox_id/enable_whatsapp_calling", middleware.RoleCheck("administrator"), h.Inbox.EnableWhatsAppCalling) // POST /api/v1/accounts/:id/inboxes/:inbox_id/disable_whatsapp_calling — disable WhatsApp Calling inboxes.POST("/:inbox_id/disable_whatsapp_calling", middleware.RoleCheck("administrator"), h.Inbox.DisableWhatsAppCalling) + inboxes.POST("/:inbox_id/set_inbound_calls", middleware.RoleCheck("administrator"), h.Inbox.SetInboundCalls) // GET /api/v1/accounts/:id/inboxes/:inbox_id/agent_bot — get currently active agent bot inboxes.GET("/:inbox_id/agent_bot", h.Inbox.GetAgentBot) @@ -1368,6 +1378,9 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { assistants.POST("/", h.CaptainAssistant.Create) assistants.GET("/tools", h.CaptainAssistant.Tools) assistants.GET("/:assistant_id", h.CaptainAssistant.Get) + assistants.GET("/:assistant_id/stats", h.CaptainAssistant.Stats) + assistants.GET("/:assistant_id/summary", h.CaptainAssistant.Summary) + assistants.GET("/:assistant_id/drilldown", h.CaptainAssistant.Drilldown) assistants.PUT("/:assistant_id", h.CaptainAssistant.Update) assistants.PATCH("/:assistant_id", h.CaptainAssistant.Update) assistants.DELETE("/:assistant_id", h.CaptainAssistant.Delete) @@ -1397,6 +1410,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { assistantScenarios.DELETE("/:scenario_id", h.CaptainScenario.Delete) } } + captain.POST("/message_reports", h.CaptainAssistant.CreateMessageReport) // Custom tools (account-level) customTools := captain.Group("/custom_tools") @@ -2005,7 +2019,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // Reference: Chatwoot config/routes.rb namespace :api/:v2. func registerV2Routes(g *gin.RouterGroup, h *Handlers) { accounts := g.Group("/accounts") - accounts.Use(middleware.AccountScope()) + accounts.Use(accountScopeMiddleware(h)) { accounts.POST("/", h.Account.Create) accountScoped := accounts.Group("/:account_id") @@ -2015,6 +2029,7 @@ func registerV2Routes(g *gin.RouterGroup, h *Handlers) { reports := accountScoped.Group("/reports") { reports.GET("", h.Analytics.Index) + reports.GET("/drilldown", middleware.RoleCheck("administrator"), h.Analytics.Drilldown) reports.GET("/summary", h.Analytics.Summary) reports.GET("/bot_summary", h.Analytics.BotSummary) reports.GET("/agents", h.Analytics.AgentMetrics) @@ -2041,6 +2056,13 @@ func registerV2Routes(g *gin.RouterGroup, h *Handlers) { } } +func accountScopeMiddleware(h *Handlers) gin.HandlerFunc { + if h != nil && h.RBAC != nil { + return middleware.AccountScopeWithService(h.RBAC) + } + return middleware.AccountScope() +} + // registerPlatformRoutes maps super-admin platform routes. // Reference: Chatwoot namespace :platform_app (super_admin only) func registerPlatformRoutes(g *gin.RouterGroup, h *Handlers) { diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index 24ac1559..35b897cc 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -5,18 +5,22 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" + "github.com/gochat/gochat/internal/worker" applogger "github.com/gochat/gochat/pkg/logger" pkgvalidator "github.com/gochat/gochat/pkg/validator" + "gorm.io/gorm" ) // AccountService implements business logic for Account operations. // Reference: Chatwoot app/controllers/api/v1/accounts_controller.rb type AccountService struct { - repo *repository.AccountRepo + repo *repository.AccountRepo + worker *worker.WorkerPool } const chatwootMaxLimit = 100000 @@ -26,6 +30,18 @@ func NewAccountService(repo *repository.AccountRepo) *AccountService { return &AccountService{repo: repo} } +func (s *AccountService) SetWorkerPool(wp *worker.WorkerPool) { + s.worker = wp + RegisterEnterpriseBillingJobs(wp, s) +} + +func (s *AccountService) DB() *gorm.DB { + if s == nil || s.repo == nil { + return nil + } + return s.repo.DB() +} + // ListByUser retrieves all accounts accessible by a user. func (s *AccountService) ListByUser(ctx context.Context, userID uint, offset, limit int) ([]model.Account, int64, error) { return s.repo.FindByUser(ctx, userID, offset, limit) @@ -41,6 +57,140 @@ func (s *AccountService) GetByUserAndID(ctx context.Context, userID, accountID u return s.repo.FindByUserAndID(ctx, userID, accountID) } +func (s *AccountService) HelpCenterGenerationStatus(ctx context.Context, accountID uint) (map[string]any, error) { + account, err := s.repo.FindByID(ctx, accountID) + if err != nil { + return nil, err + } + attrs := account.CustomAttributesMap() + var articlesCount int64 + var categoriesCount int64 + if s.repo.DB() != nil { + _ = s.repo.DB().WithContext(ctx).Model(&model.Article{}).Where("account_id = ?", accountID).Count(&articlesCount).Error + _ = s.repo.DB().WithContext(ctx).Model(&model.Category{}).Where("account_id = ?", accountID).Count(&categoriesCount).Error + } + return map[string]any{ + "generation_id": attrs["help_center_generation_id"], + "state": attrs["help_center_generation_state"], + "articles_count": articlesCount, + "categories_count": categoriesCount, + }, nil +} + +var supportedBillingCurrencies = map[string]struct{}{"usd": {}, "brl": {}} + +func (s *AccountService) SelectBillingCurrency(ctx context.Context, userID, accountID uint, currency string) error { + account, err := s.repo.FindByUserAndID(ctx, userID, accountID) + if err != nil { + return err + } + attrs := account.CustomAttributesMap() + if isNonEmptyAccountAttribute(attrs["stripe_customer_id"]) || isNonEmptyAccountAttribute(attrs["is_creating_customer"]) { + return errors.New("Billing currency is locked") + } + if !s.billingCurrencySelectionRequired(ctx, account) { + return errors.New("Invalid billing currency") + } + currency = strings.ToLower(strings.TrimSpace(currency)) + if _, ok := supportedBillingCurrencies[currency]; !ok { + return errors.New("Invalid billing currency") + } + attrs["billing_currency"] = currency + if err := account.SetCustomAttributesMap(attrs); err != nil { + return err + } + if err := s.repo.Update(ctx, account); err != nil { + return err + } + return s.EnsureEnterpriseAccountCustomerCreationFlag(ctx, accountID, userID) +} + +func (s *AccountService) EnterpriseSubscription(ctx context.Context, userID, accountID uint) (map[string]any, bool, error) { + account, err := s.repo.FindByUserAndID(ctx, userID, accountID) + if err != nil { + return nil, false, err + } + if s.billingCurrencySelectionRequired(ctx, account) { + return map[string]any{"currency_selection_required": true, "currency_options": []string{"usd", "brl"}, "suggested_currency": billingCurrencyForLocale(account.Locale)}, true, nil + } + return nil, false, s.EnsureEnterpriseAccountCustomerCreationFlag(ctx, accountID, userID) +} + +func (s *AccountService) EnterpriseTopupOptions(ctx context.Context, userID, accountID uint) (map[string]any, error) { + account, err := s.repo.FindByUserAndID(ctx, userID, accountID) + if err != nil { + return nil, err + } + currency := s.accountBillingCurrency(ctx, account) + options := make([]map[string]any, 0) + if s.repo.DB() != nil { + var config model.InstallationConfig + if err := s.repo.DB().WithContext(ctx).Where("name = ?", "CAPTAIN_TOPUP_OPTIONS").First(&config).Error; err == nil { + var byCurrency map[string][]map[string]any + if json.Unmarshal([]byte(config.Value), &byCurrency) == nil { + optionCurrency := currency + if len(byCurrency[optionCurrency]) == 0 { + optionCurrency = "usd" + } + for _, option := range byCurrency[optionCurrency] { + option["currency"] = optionCurrency + options = append(options, option) + } + } + } + } + return map[string]any{"id": account.ID, "currency": currency, "options": options}, nil +} + +func (s *AccountService) billingCurrencySelectionRequired(ctx context.Context, account *model.Account) bool { + if account == nil || !s.multiCurrencyBillingEnabled(ctx) { + return false + } + attrs := account.CustomAttributesMap() + if isNonEmptyAccountAttribute(attrs["stripe_customer_id"]) { + return false + } + stored := strings.ToLower(strings.TrimSpace(fmt.Sprint(attrs["billing_currency"]))) + if _, ok := supportedBillingCurrencies[stored]; ok { + return false + } + return billingCurrencyForLocale(account.Locale) != "usd" +} + +func (s *AccountService) accountBillingCurrency(ctx context.Context, account *model.Account) string { + if account == nil || !s.multiCurrencyBillingEnabled(ctx) { + return "usd" + } + attrs := account.CustomAttributesMap() + stored := strings.ToLower(strings.TrimSpace(fmt.Sprint(attrs["billing_currency"]))) + if _, ok := supportedBillingCurrencies[stored]; ok { + return stored + } + if isNonEmptyAccountAttribute(attrs["stripe_customer_id"]) { + return "usd" + } + return billingCurrencyForLocale(account.Locale) +} + +func (s *AccountService) multiCurrencyBillingEnabled(ctx context.Context) bool { + var config model.InstallationConfig + if s.repo == nil || s.repo.DB() == nil || s.repo.DB().WithContext(ctx).Where("name = ?", "ENABLE_MULTI_CURRENCY_BILLING").First(&config).Error != nil { + return false + } + return !strings.EqualFold(strings.TrimSpace(config.Value), "false") +} + +func billingCurrencyForLocale(locale string) string { + if locale == "pt_BR" { + return "brl" + } + return "usd" +} + +func isNonEmptyAccountAttribute(value any) bool { + return value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" +} + // CreateAccountRequest is the DTO for creating an account. type CreateAccountRequest struct { Name string `json:"name,omitempty" validate:"omitempty,min=2"` @@ -427,7 +577,14 @@ func (s *AccountService) EnsureEnterpriseAccountCustomerCreationFlag(ctx context if err := account.SetCustomAttributesMap(attrs); err != nil { return err } - return s.repo.Update(ctx, account) + if err := s.repo.Update(ctx, account); err != nil { + return err + } + if s.worker == nil { + return nil + } + _, err = s.worker.Enqueue(ctx, TaskTypeEnterpriseCreateStripeCustomer, enterpriseCreateStripeCustomerJob{AccountID: accountID}, worker.WithQueue("default"), worker.WithMaxAttempts(3), worker.WithIdempotencyKey(fmt.Sprintf("stripe-customer:%d", accountID))) + return err } func accountUsageLimit(limit int) int { diff --git a/backend/internal/service/analytics_service.go b/backend/internal/service/analytics_service.go index 9244ad93..308b416f 100644 --- a/backend/internal/service/analytics_service.go +++ b/backend/internal/service/analytics_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "fmt" "time" "github.com/gochat/gochat/internal/model" @@ -124,6 +125,27 @@ type InboxLabelMatrixFilter struct { LabelIDs []uint } +type ReportDrilldownParams struct { + Metric, DimensionType, GroupBy string + DimensionID uint + Since, Until, BucketTimestamp time.Time + TimezoneOffset float64 + BusinessHours bool + Page, PerPage int +} + +type ReportDrilldownResult struct { + Meta map[string]any `json:"meta"` + Payload []map[string]any `json:"payload"` +} + +var reportDrilldownMetrics = map[string]string{ + "conversations_count": "", "incoming_messages_count": "", "outgoing_messages_count": "", + "avg_first_response_time": "first_response", "avg_resolution_time": "conversation_resolved", + "reply_time": "reply_time", "resolutions_count": "conversation_resolved", + "bot_resolutions_count": "conversation_bot_resolved", "bot_handoffs_count": "conversation_bot_handoff", +} + // GetSummary returns account-level aggregated metrics for a date range. func (s *AnalyticsService) GetSummary(ctx context.Context, accountID uint, since, until time.Time) (*SummaryResponse, error) { if err := s.EnsureRollupsForRange(ctx, accountID, since, until); err != nil { @@ -166,6 +188,297 @@ func (s *AnalyticsService) GetSummary(ctx context.Context, accountID uint, since return &SummaryResponse{Metrics: metrics}, nil } +// GetDrilldown returns the raw records behind one chart bucket using the exact +// meta/payload envelope consumed by Chatwoot's report drilldown drawer. +func (s *AnalyticsService) GetDrilldown(ctx context.Context, accountID uint, params ReportDrilldownParams) (*ReportDrilldownResult, error) { + rawEvent, supported := reportDrilldownMetrics[params.Metric] + if !supported { + return nil, fmt.Errorf("unsupported metric") + } + if params.Page < 1 { + params.Page = 1 + } + if params.PerPage <= 0 { + params.PerPage = 25 + } + if params.PerPage > 100 { + params.PerPage = 100 + } + if params.GroupBy == "" { + params.GroupBy = "day" + } + if err := s.validateReportDimension(ctx, accountID, params); err != nil { + return nil, err + } + bucketEnd := reportBucketEnd(params.BucketTimestamp, params.GroupBy, params.TimezoneOffset) + bucketStart := params.BucketTimestamp + if bucketStart.Before(params.Since) { + bucketStart = params.Since + } + if bucketEnd.After(params.Until) { + bucketEnd = params.Until + } + if !bucketEnd.After(bucketStart) { + return nil, fmt.Errorf("invalid bucket range") + } + + recordType := "conversation" + var payload []map[string]any + var total, conversationCount int64 + if params.Metric == "incoming_messages_count" || params.Metric == "outgoing_messages_count" { + recordType = "message" + messageType := "incoming" + if params.Metric == "outgoing_messages_count" { + messageType = "outgoing" + } + messageScope := func() *gorm.DB { + return s.reportMessageDimensionQuery(ctx, accountID, params).Where("messages.created_at >= ? AND messages.created_at < ? AND messages.message_type = ?", bucketStart, bucketEnd, messageType) + } + if err := messageScope().Count(&total).Error; err != nil { + return nil, err + } + if err := messageScope().Distinct("messages.conversation_id").Count(&conversationCount).Error; err != nil { + return nil, err + } + var messages []model.Message + if err := messageScope().Preload("Conversation.Contact").Preload("Conversation.Inbox").Preload("Conversation.Assignee").Order("messages.created_at DESC").Offset((params.Page - 1) * params.PerPage).Limit(params.PerPage).Find(&messages).Error; err != nil { + return nil, err + } + payload = make([]map[string]any, 0, len(messages)) + for i := range messages { + payload = append(payload, s.reportMessageRecord(ctx, &messages[i], nil, nil)) + } + } else if params.Metric == "conversations_count" { + conversationScope := func() *gorm.DB { + return s.reportConversationDimensionQuery(ctx, accountID, params).Where("conversations.created_at >= ? AND conversations.created_at < ?", bucketStart, bucketEnd) + } + if err := conversationScope().Count(&total).Error; err != nil { + return nil, err + } + conversationCount = total + var conversations []model.Conversation + if err := conversationScope().Preload("Contact").Preload("Inbox").Preload("Assignee").Order("conversations.created_at DESC").Offset((params.Page - 1) * params.PerPage).Limit(params.PerPage).Find(&conversations).Error; err != nil { + return nil, err + } + payload = make([]map[string]any, 0, len(conversations)) + for i := range conversations { + payload = append(payload, s.reportConversationRecord(ctx, &conversations[i], nil, nil, "")) + } + } else { + eventScope := func() *gorm.DB { + q := s.reportEventDimensionQuery(ctx, accountID, params).Where("reporting_events.name = ? AND reporting_events.created_at >= ? AND reporting_events.created_at < ?", rawEvent, bucketStart, bucketEnd) + if params.Metric == "bot_resolutions_count" { + handoffs := s.reportEventDimensionQuery(ctx, accountID, params).Select("reporting_events.conversation_id").Where("reporting_events.name = ? AND reporting_events.created_at BETWEEN ? AND ? AND reporting_events.conversation_id IS NOT NULL", "conversation_bot_handoff", params.Since, params.Until) + q = q.Where("reporting_events.conversation_id NOT IN (?)", handoffs) + } + if params.Metric == "bot_handoffs_count" { + distinctEvents := s.reportEventDimensionQuery(ctx, accountID, params).Select("MAX(reporting_events.id)").Where("reporting_events.name = ? AND reporting_events.created_at >= ? AND reporting_events.created_at < ? AND reporting_events.conversation_id IS NOT NULL", rawEvent, bucketStart, bucketEnd).Group("reporting_events.conversation_id") + q = q.Where("reporting_events.id IN (?)", distinctEvents) + } + return q + } + if err := eventScope().Count(&total).Error; err != nil { + return nil, err + } + if err := eventScope().Distinct("reporting_events.conversation_id").Count(&conversationCount).Error; err != nil { + return nil, err + } + var events []model.ReportingEvent + if err := eventScope().Preload("Conversation.Contact").Preload("Conversation.Inbox").Preload("Conversation.Assignee").Order("reporting_events.created_at DESC").Offset((params.Page - 1) * params.PerPage).Limit(params.PerPage).Find(&events).Error; err != nil { + return nil, err + } + if params.Metric == "avg_first_response_time" || params.Metric == "reply_time" { + recordType = "message" + } + payload = make([]map[string]any, 0, len(events)) + for i := range events { + payload = append(payload, s.reportEventRecord(ctx, &events[i], params)) + } + } + return &ReportDrilldownResult{Meta: map[string]any{"metric": params.Metric, "record_type": recordType, "bucket": map[string]any{"since": bucketStart.Unix(), "until": bucketEnd.Unix()}, "current_page": params.Page, "per_page": params.PerPage, "total_count": total, "conversation_count": conversationCount}, Payload: payload}, nil +} + +func (s *AnalyticsService) validateReportDimension(ctx context.Context, accountID uint, params ReportDrilldownParams) error { + if params.DimensionType == "account" { + return nil + } + var count int64 + q := s.db.WithContext(ctx) + switch params.DimensionType { + case "inbox": + q = q.Model(&model.Inbox{}).Where("id = ? AND account_id = ?", params.DimensionID, accountID) + case "agent": + q = q.Model(&model.AccountUser{}).Where("user_id = ? AND account_id = ?", params.DimensionID, accountID) + case "label": + q = q.Model(&model.Tag{}).Where("id = ? AND account_id = ?", params.DimensionID, accountID) + case "team": + q = q.Model(&model.Team{}).Where("id = ? AND account_id = ?", params.DimensionID, accountID) + default: + return fmt.Errorf("unsupported dimension type") + } + if err := q.Count(&count).Error; err != nil { + return err + } + if count == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (s *AnalyticsService) reportMessageDimensionQuery(ctx context.Context, accountID uint, params ReportDrilldownParams) *gorm.DB { + q := s.db.WithContext(ctx).Model(&model.Message{}).Where("messages.account_id = ?", accountID) + switch params.DimensionType { + case "inbox": + q = q.Where("messages.inbox_id = ?", params.DimensionID) + case "agent": + q = q.Where("messages.sender_id = ? AND messages.sender_type IN ?", params.DimensionID, []string{"User", "agent", "user"}) + case "team": + q = q.Joins("JOIN conversations drilldown_conversations ON drilldown_conversations.id = messages.conversation_id").Where("drilldown_conversations.team_id = ?", params.DimensionID) + case "label": + q = q.Joins("JOIN conversation_labels drilldown_labels ON drilldown_labels.conversation_id = messages.conversation_id").Where("drilldown_labels.tag_id = ?", params.DimensionID) + } + return q +} + +func (s *AnalyticsService) reportConversationDimensionQuery(ctx context.Context, accountID uint, params ReportDrilldownParams) *gorm.DB { + q := s.db.WithContext(ctx).Model(&model.Conversation{}).Where("conversations.account_id = ?", accountID) + switch params.DimensionType { + case "inbox": + q = q.Where("conversations.inbox_id = ?", params.DimensionID) + case "agent": + q = q.Where("conversations.assignee_id = ?", params.DimensionID) + case "team": + q = q.Where("conversations.team_id = ?", params.DimensionID) + case "label": + q = q.Joins("JOIN conversation_labels drilldown_labels ON drilldown_labels.conversation_id = conversations.id").Where("drilldown_labels.tag_id = ?", params.DimensionID) + } + return q +} + +func (s *AnalyticsService) reportEventDimensionQuery(ctx context.Context, accountID uint, params ReportDrilldownParams) *gorm.DB { + q := s.db.WithContext(ctx).Model(&model.ReportingEvent{}).Where("reporting_events.account_id = ?", accountID) + switch params.DimensionType { + case "inbox": + q = q.Where("reporting_events.inbox_id = ?", params.DimensionID) + case "agent": + q = q.Where("reporting_events.user_id = ?", params.DimensionID) + case "team": + q = q.Joins("JOIN conversations drilldown_conversations ON drilldown_conversations.id = reporting_events.conversation_id").Where("drilldown_conversations.team_id = ?", params.DimensionID) + case "label": + q = q.Joins("JOIN conversation_labels drilldown_labels ON drilldown_labels.conversation_id = reporting_events.conversation_id").Where("drilldown_labels.tag_id = ?", params.DimensionID) + } + return q +} + +func (s *AnalyticsService) reportEventRecord(ctx context.Context, event *model.ReportingEvent, params ReportDrilldownParams) map[string]any { + metricValue := event.Value + if params.BusinessHours { + metricValue = event.ValueInBusinessHours + } + occurredAt := event.CreatedAt + if !event.EventEndTime.IsZero() { + occurredAt = event.EventEndTime + } + if params.Metric == "avg_first_response_time" || params.Metric == "reply_time" { + var message model.Message + q := s.db.WithContext(ctx).Preload("Conversation.Contact").Preload("Conversation.Inbox").Preload("Conversation.Assignee").Where("account_id = ? AND conversation_id = ? AND message_type IN ?", event.AccountID, event.ConversationID, []string{"outgoing", "template"}) + if !event.EventEndTime.IsZero() { + q = q.Where("created_at BETWEEN ? AND ?", event.EventEndTime.Add(-time.Second), event.EventEndTime.Add(time.Second)) + } + if err := q.Order("created_at DESC, id DESC").First(&message).Error; err == nil { + return s.reportMessageRecord(ctx, &message, &metricValue, &occurredAt) + } + } + return s.reportConversationRecord(ctx, event.Conversation, &metricValue, &occurredAt, event.Name) +} + +func (s *AnalyticsService) reportMessageRecord(ctx context.Context, message *model.Message, metricValue *float64, occurredAt *time.Time) map[string]any { + when := message.CreatedAt + if occurredAt != nil { + when = *occurredAt + } + return map[string]any{"record_type": "message", "conversation": s.reportConversationAttributes(ctx, message.Conversation), "message": map[string]any{"id": message.ID, "content": message.Content, "message_type": message.MessageType, "sender_name": s.reportSenderName(ctx, message), "created_at": message.CreatedAt.Unix()}, "metric_value": metricValue, "occurred_at": when.Unix()} +} + +func (s *AnalyticsService) reportConversationRecord(ctx context.Context, conversation *model.Conversation, metricValue *float64, occurredAt *time.Time, eventName string) map[string]any { + when := int64(0) + if conversation != nil { + when = conversation.CreatedAt.Unix() + } + if occurredAt != nil { + when = occurredAt.Unix() + } + record := map[string]any{"record_type": "conversation", "conversation": s.reportConversationAttributes(ctx, conversation), "message": nil, "metric_value": metricValue, "occurred_at": when} + if eventName != "" { + record["event_name"] = eventName + } + return record +} + +func (s *AnalyticsService) reportConversationAttributes(ctx context.Context, conversation *model.Conversation) map[string]any { + if conversation == nil || conversation.ID == 0 { + return map[string]any{} + } + var last model.Message + _ = s.db.WithContext(ctx).Where("conversation_id = ? AND message_type <> ?", conversation.ID, "activity").Order("created_at DESC, id DESC").First(&last).Error + var lastPayload any + if last.ID != 0 { + lastPayload = map[string]any{"id": last.ID, "content": last.Content, "message_type": last.MessageType, "sender_name": s.reportSenderName(ctx, &last), "created_at": last.CreatedAt.Unix()} + } + assigneeName := "" + if conversation.Assignee != nil { + assigneeName = conversation.Assignee.Name + } + return map[string]any{"id": conversation.ID, "display_id": conversation.DisplayID, "contact_id": conversation.ContactID, "contact_name": conversation.Contact.Name, "inbox_id": conversation.InboxID, "inbox_name": conversation.Inbox.Name, "assignee_id": conversation.AssigneeID, "assignee_name": assigneeName, "status": conversation.Status, "created_at": conversation.CreatedAt.Unix(), "last_activity_at": reportInt64Value(conversation.LastActivityAt), "last_message": lastPayload} +} + +func (s *AnalyticsService) reportSenderName(ctx context.Context, message *model.Message) any { + if message.SenderID == nil { + return nil + } + if message.SenderType == "Captain::Assistant" || message.SenderType == "CaptainAssistant" || message.SenderType == "captain_assistant" { + var assistant model.CaptainAssistant + if s.db.WithContext(ctx).First(&assistant, *message.SenderID).Error == nil { + return assistant.Name + } + return nil + } + if message.SenderType == "Contact" || message.SenderType == "contact" { + var contact model.Contact + if s.db.WithContext(ctx).First(&contact, *message.SenderID).Error == nil { + return contact.Name + } + } + var user model.User + if s.db.WithContext(ctx).First(&user, *message.SenderID).Error == nil { + return user.Name + } + return nil +} + +func reportBucketEnd(start time.Time, groupBy string, timezoneOffset float64) time.Time { + location := time.FixedZone("report", int(timezoneOffset*3600)) + start = start.In(location) + switch groupBy { + case "hour": + return start.Add(time.Hour).UTC() + case "week": + return start.AddDate(0, 0, 7).UTC() + case "month": + return start.AddDate(0, 1, 0).UTC() + case "year": + return start.AddDate(1, 0, 0).UTC() + default: + return start.AddDate(0, 0, 1).UTC() + } +} +func reportInt64Value(value *int64) int64 { + if value == nil { + return 0 + } + return *value +} + // GetAgentMetrics returns metrics grouped by agent dimension. func (s *AnalyticsService) GetAgentMetrics(ctx context.Context, accountID uint, since, until time.Time) ([]DimensionMetrics, error) { return s.getDimensionMetrics(ctx, accountID, model.DimensionAgent, since, until) diff --git a/backend/internal/service/assignable_agent_service.go b/backend/internal/service/assignable_agent_service.go index 104acbdf..0c7c3314 100644 --- a/backend/internal/service/assignable_agent_service.go +++ b/backend/internal/service/assignable_agent_service.go @@ -190,6 +190,21 @@ func (s *AssignableAgentService) GetAssignableAgents(ctx context.Context, accoun return dtos, nil } +// GetAssignableAgentBots returns global and account-owned bots available to the +// current account. Chatwoot intentionally opt-ins this list through +// include_agent_bots so older clients continue receiving user-only payloads. +func (s *AssignableAgentService) GetAssignableAgentBots(ctx context.Context, accountID uint) ([]model.AgentBot, error) { + if s == nil || s.accountRepo == nil || s.accountRepo.DB() == nil { + return []model.AgentBot{}, nil + } + var bots []model.AgentBot + err := s.accountRepo.DB().WithContext(ctx). + Where("account_id IS NULL OR account_id = ?", accountID). + Order("id ASC"). + Find(&bots).Error + return bots, err +} + // findAdministrators 返回account的所有管理员级别的用户。 func (s *AssignableAgentService) findAdministrators(ctx context.Context, accountID uint) ([]model.User, error) { adminIDs, err := s.findAdministratorIDs(ctx, accountID) diff --git a/backend/internal/service/assignment_policy_service.go b/backend/internal/service/assignment_policy_service.go index 576cc143..8dc1f3b1 100644 --- a/backend/internal/service/assignment_policy_service.go +++ b/backend/internal/service/assignment_policy_service.go @@ -49,6 +49,7 @@ type CreatePolicyRequest struct { ConversationPriority string `json:"conversation_priority,omitempty" validate:"omitempty,oneof=earliest_created longest_waiting"` FairDistributionLimit int `json:"fair_distribution_limit,omitempty"` FairDistributionWindow int `json:"fair_distribution_window,omitempty"` + ExcludeOlderThanHours *int `json:"exclude_older_than_hours,omitempty" validate:"omitempty,gt=0"` Enabled *bool `json:"enabled,omitempty"` } @@ -60,6 +61,7 @@ type UpdatePolicyRequest struct { ConversationPriority *string `json:"conversation_priority,omitempty" validate:"omitempty,oneof=earliest_created longest_waiting"` FairDistributionLimit *int `json:"fair_distribution_limit,omitempty"` FairDistributionWindow *int `json:"fair_distribution_window,omitempty"` + ExcludeOlderThanHours *int `json:"exclude_older_than_hours,omitempty" validate:"omitempty,gt=0"` Enabled *bool `json:"enabled,omitempty"` } @@ -144,6 +146,7 @@ func (s *AssignmentPolicyService) CreateAccountPolicy(ctx context.Context, accou ConversationPriority: conversationPriority, FairDistributionLimit: limit, FairDistributionWindow: window, + ExcludeOlderThanHours: req.ExcludeOlderThanHours, Enabled: enabled, } @@ -181,6 +184,9 @@ func (s *AssignmentPolicyService) UpdateAccountPolicy(ctx context.Context, id, a if req.FairDistributionWindow != nil { policy.FairDistributionWindow = *req.FairDistributionWindow } + if req.ExcludeOlderThanHours != nil { + policy.ExcludeOlderThanHours = req.ExcludeOlderThanHours + } if req.Enabled != nil { policy.Enabled = *req.Enabled } @@ -271,6 +277,7 @@ func (s *AssignmentPolicyService) SerializePolicy(ctx context.Context, policy *m "conversation_priority": conversationPriorityFromValue(policy.ConversationPriority), "fair_distribution_limit": policy.FairDistributionLimit, "fair_distribution_window": policy.FairDistributionWindow, + "exclude_older_than_hours": policy.ExcludeOlderThanHours, "enabled": policy.Enabled, "assigned_inbox_count": count, "created_at": unixSeconds(policy.CreatedAt), diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 3dfd45fc..e19f6a30 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -5,11 +5,13 @@ import ( "crypto/rand" "crypto/sha256" "encoding/hex" + "errors" "fmt" "strings" "time" "gorm.io/gorm" + "gorm.io/gorm/clause" "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/model" @@ -63,6 +65,89 @@ type LoginOutput struct { AccountID uint Role string MFARequired bool + ClientID string +} + +func (s *AuthService) TrackChatwootSession(ctx context.Context, output *LoginOutput, requestedClientID, ipAddress, userAgent string) error { + if output == nil || output.User == nil { + return errors.New("login output is required") + } + clientID := strings.TrimSpace(requestedClientID) + if clientID == "" { + bytes := make([]byte, 16) + if _, err := rand.Read(bytes); err != nil { + return fmt.Errorf("generate session client id: %w", err) + } + clientID = hex.EncodeToString(bytes) + } + pair, err := s.jwtService.GenerateTokenPairForClient(output.User, output.AccountID, output.Role, clientID) + if err != nil { + return err + } + if err := s.refreshStore.StoreForClient(ctx, output.User.ID, clientID, pair.RefreshToken); err != nil { + return err + } + _ = s.refreshStore.Revoke(ctx, output.User.ID) + now := time.Now().UTC() + browserName, browserVersion, deviceName, platformName, platformVersion := chatwootSessionUserAgent(userAgent) + session := model.UserSession{ + UserID: output.User.ID, + ClientID: clientID, + IPAddress: ipAddress, + UserAgent: userAgent, + BrowserName: browserName, + BrowserVersion: browserVersion, + DeviceName: deviceName, + PlatformName: platformName, + PlatformVersion: platformVersion, + LastActivityAt: &now, + } + if err := s.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "client_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"ip_address", "user_agent", "browser_name", "browser_version", "device_name", "platform_name", "platform_version", "last_activity_at", "updated_at"}), + }).Create(&session).Error; err != nil { + return err + } + output.TokenPair = pair + output.ClientID = clientID + return nil +} + +func (s *AuthService) RevokeChatwootSession(ctx context.Context, userID uint, clientID string) error { + if strings.TrimSpace(clientID) != "" { + if err := s.db.WithContext(ctx).Where("user_id = ? AND client_id = ?", userID, clientID).Delete(&model.UserSession{}).Error; err != nil { + return err + } + } + return s.refreshStore.RevokeClient(ctx, userID, clientID) +} + +func chatwootSessionUserAgent(userAgent string) (browserName, browserVersion, deviceName, platformName, platformVersion string) { + browserName = "Unknown" + deviceName = "Desktop" + platformName = "Unknown" + for _, candidate := range []struct{ marker, name string }{{"Edg/", "Edge"}, {"Chrome/", "Chrome"}, {"Firefox/", "Firefox"}, {"Version/", "Safari"}} { + if idx := strings.Index(userAgent, candidate.marker); idx >= 0 { + browserName = candidate.name + if fields := strings.Fields(userAgent[idx+len(candidate.marker):]); len(fields) > 0 { + browserVersion = strings.TrimRight(fields[0], ");") + } + break + } + } + switch { + case strings.Contains(userAgent, "Windows NT"): + platformName = "Windows" + case strings.Contains(userAgent, "Mac OS X"): + platformName = "macOS" + case strings.Contains(userAgent, "Android"): + platformName, deviceName = "Android", "Mobile" + case strings.Contains(userAgent, "iPhone") || strings.Contains(userAgent, "iPad"): + platformName, deviceName = "iOS", "Mobile" + case strings.Contains(userAgent, "Linux"): + platformName = "Linux" + } + return } // Login authenticates a user by email+password. @@ -148,6 +233,16 @@ func (s *AuthService) ValidateAccessToken(ctx context.Context, accessToken strin if err != nil { return nil, err } + if claims.ClientID != "" { + var session model.UserSession + if err := s.db.WithContext(ctx).Where("user_id = ? AND client_id = ?", claims.UserID, claims.ClientID).First(&session).Error; err != nil { + return nil, errors.New("session revoked") + } + if session.LastActivityAt == nil || session.LastActivityAt.Before(time.Now().Add(-5*time.Minute)) { + now := time.Now().UTC() + _ = s.db.WithContext(ctx).Model(&session).Updates(map[string]any{"last_activity_at": now, "updated_at": now}).Error + } + } var user model.User if err := s.db.WithContext(ctx).First(&user, claims.UserID).Error; err != nil { @@ -163,7 +258,7 @@ func (s *AuthService) ValidateAccessToken(ctx context.Context, accessToken strin } } - return &LoginOutput{User: &user, AccountID: accountID, Role: role}, nil + return &LoginOutput{User: &user, AccountID: accountID, Role: role, ClientID: claims.ClientID}, nil } // LoginWithMFA completes login after MFA verification. @@ -236,7 +331,7 @@ func (s *AuthService) Refresh(ctx context.Context, input *RefreshInput) (*Refres } // Check refresh token exists in Redis (prevents reuse after logout) - valid, err := s.refreshStore.Validate(ctx, claims.UserID, input.RefreshToken) + valid, err := s.refreshStore.ValidateForClient(ctx, claims.UserID, claims.ClientID, input.RefreshToken) if err != nil { return nil, fmt.Errorf("refresh token validation failed: %w", err) } @@ -257,13 +352,13 @@ func (s *AuthService) Refresh(ctx context.Context, input *RefreshInput) (*Refres } // Generate new token pair - tokenPair, err := s.jwtService.GenerateTokenPair(&user, accountID, role) + tokenPair, err := s.jwtService.GenerateTokenPairForClient(&user, accountID, role, claims.ClientID) if err != nil { return nil, fmt.Errorf("failed to generate tokens: %w", err) } // Rotate refresh token in Redis (old token revoked, new token stored) - if err := s.refreshStore.Rotate(ctx, claims.UserID, tokenPair.RefreshToken); err != nil { + if err := s.refreshStore.RotateForClient(ctx, claims.UserID, claims.ClientID, tokenPair.RefreshToken); err != nil { return nil, fmt.Errorf("failed to rotate refresh token: %w", err) } diff --git a/backend/internal/service/auth_service_test.go b/backend/internal/service/auth_service_test.go index dc51b63e..e36d8a2d 100644 --- a/backend/internal/service/auth_service_test.go +++ b/backend/internal/service/auth_service_test.go @@ -20,7 +20,7 @@ func setupAuthServiceTest(t *testing.T) (*AuthService, *gorm.DB, *model.User) { t.Helper() db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{})) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{}, &model.UserSession{})) account := &model.Account{Name: "Auth Service", Status: "active"} require.NoError(t, db.Create(account).Error) passwordDigest, err := crypto.HashPassword("oldpassword") diff --git a/backend/internal/service/captain_assistant_response_service.go b/backend/internal/service/captain_assistant_response_service.go index 18012f0d..118722c2 100644 --- a/backend/internal/service/captain_assistant_response_service.go +++ b/backend/internal/service/captain_assistant_response_service.go @@ -165,11 +165,13 @@ Keep your response concise (max ~%d characters). // Optionally send as a message in the conversation if req.SendMessage && conversation != nil { + senderID := req.AssistantID msg := &model.Message{ ConversationID: req.ConversationID, AccountID: accountID, InboxID: conversation.InboxID, - SenderType: "bot", + SenderID: &senderID, + SenderType: "Captain::Assistant", Content: content, ContentType: "text", MessageType: "outgoing", diff --git a/backend/internal/service/captain_assistant_service.go b/backend/internal/service/captain_assistant_service.go index 9f459231..2d6843ab 100644 --- a/backend/internal/service/captain_assistant_service.go +++ b/backend/internal/service/captain_assistant_service.go @@ -4,12 +4,17 @@ import ( "context" "encoding/json" "fmt" + "math" + "strconv" "strings" + "time" "github.com/gochat/gochat/internal/llm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" ) // CaptainAssistantService implements business logic for CaptainAssistant operations. @@ -20,6 +25,7 @@ type CaptainAssistantService struct { documentRepo *repository.CaptainDocumentRepo responseRepo *repository.CaptainAssistantResponseRepo llmProvider llm.Provider + cache *redis.Client } // NewCaptainAssistantService creates a new CaptainAssistantService. @@ -29,14 +35,19 @@ func NewCaptainAssistantService( documentRepo *repository.CaptainDocumentRepo, responseRepo *repository.CaptainAssistantResponseRepo, llmProvider llm.Provider, + cache ...*redis.Client, ) *CaptainAssistantService { - return &CaptainAssistantService{ + svc := &CaptainAssistantService{ assistantRepo: assistantRepo, inboxRepo: inboxRepo, documentRepo: documentRepo, responseRepo: responseRepo, llmProvider: llmProvider, } + if len(cache) > 0 { + svc.cache = cache[0] + } + return svc } // --- Request DTOs --- @@ -71,6 +82,50 @@ type PlaygroundRequest struct { MessageHistory []PlaygroundMessage `json:"message_history"` } +type CaptainMetricValue struct { + Current float64 `json:"current"` + Previous float64 `json:"previous"` + Trend float64 `json:"trend"` +} + +type CaptainKnowledgeStats struct { + Approved int64 `json:"approved"` + Pending int64 `json:"pending"` + Documents int64 `json:"documents"` + Coverage float64 `json:"coverage"` +} + +type CaptainAssistantStats struct { + ConversationsHandled CaptainMetricValue `json:"conversations_handled"` + AutoResolutionRate CaptainMetricValue `json:"auto_resolution_rate"` + HandoffRate CaptainMetricValue `json:"handoff_rate"` + HoursSaved CaptainMetricValue `json:"hours_saved"` + ReopenRate CaptainMetricValue `json:"reopen_rate"` + ConversationDepth CaptainMetricValue `json:"conversation_depth"` + Knowledge CaptainKnowledgeStats `json:"knowledge"` +} + +type CaptainDrilldownParams struct { + Metric string + Range string + TimezoneOffset float64 + Page int + PerPage int +} + +type CaptainDrilldownResult struct { + Meta map[string]any `json:"meta"` + Payload []map[string]any `json:"payload"` +} + +var captainReportReasons = map[string]bool{ + "incorrect_information": true, "inappropriate_response": true, "incomplete_response": true, + "outdated_information": true, "other": true, +} + +var captainResolvedEvents = []string{"conversation_captain_inference_resolved", "conversation_bot_resolved"} +var captainHandoffEvents = []string{"conversation_captain_inference_handoff", "conversation_bot_handoff"} + // --- CRUD Operations --- // Create creates a new CaptainAssistant. @@ -173,6 +228,302 @@ func (s *CaptainAssistantService) List(ctx context.Context, accountID uint, offs return assistants, count, nil } +func (s *CaptainAssistantService) Stats(ctx context.Context, accountID, assistantID uint, rangeValue string, timezoneOffset float64) (*CaptainAssistantStats, error) { + if _, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, assistantID); err != nil { + return nil, err + } + currentStart, currentEnd, previousStart, previousEnd := captainStatsWindows(rangeValue, timezoneOffset, time.Now()) + current, err := s.captainWindowMetrics(ctx, accountID, assistantID, currentStart, currentEnd) + if err != nil { + return nil, err + } + previous, err := s.captainWindowMetrics(ctx, accountID, assistantID, previousStart, previousEnd) + if err != nil { + return nil, err + } + knowledge, err := s.captainKnowledgeStats(ctx, assistantID) + if err != nil { + return nil, err + } + return &CaptainAssistantStats{ + ConversationsHandled: captainPack(current.handled, previous.handled, true), + AutoResolutionRate: captainPack(current.autoResolutionRate, previous.autoResolutionRate, false), + HandoffRate: captainPack(current.handoffRate, previous.handoffRate, false), + HoursSaved: captainPack(current.hoursSaved, previous.hoursSaved, true), + ReopenRate: captainPack(current.reopenRate, previous.reopenRate, false), + ConversationDepth: captainPack(current.depth, previous.depth, false), + Knowledge: knowledge, + }, nil +} + +func (s *CaptainAssistantService) Summary(ctx context.Context, accountID, assistantID, userID uint, rangeValue string, timezoneOffset float64) (string, error) { + cacheKey := fmt.Sprintf("captain_overview_summary/%d/%d/%s/%s", assistantID, userID, rangeValue, time.Now().Format("2006-01-02")) + if s.cache != nil { + if cached, err := s.cache.Get(ctx, cacheKey).Result(); err == nil { + return cached, nil + } + } + if s.llmProvider == nil { + return "", fmt.Errorf("Captain summary is unavailable") + } + stats, err := s.Stats(ctx, accountID, assistantID, rangeValue, timezoneOffset) + if err != nil { + return "", err + } + var user model.User + _ = s.assistantRepo.DB().WithContext(ctx).First(&user, userID).Error + firstName := "" + if names := strings.Fields(user.Name); len(names) > 0 { + firstName = names[0] + } + statsJSON, _ := json.Marshal(stats) + response, err := s.llmProvider.ChatCompletion(llm.WithAccountFeature(ctx, accountID, "assistant"), llm.ChatRequest{ + Messages: []llm.ChatMessage{ + {Role: "system", Content: "Summarize these Captain support metrics in one concise, useful paragraph. Address the user by first name when available. Return only the summary."}, + {Role: "user", Content: fmt.Sprintf("User: %s\nPeriod: %s\nMetrics: %s", firstName, rangeValue, statsJSON)}, + }, + Temperature: 0.3, + MaxTokens: 300, + }) + if err != nil { + return "", err + } + if response == nil || len(response.Choices) == 0 || strings.TrimSpace(response.Choices[0].Message.Content) == "" { + return "", fmt.Errorf("Captain summary is unavailable") + } + message := strings.TrimSpace(response.Choices[0].Message.Content) + if s.cache != nil { + _ = s.cache.Set(ctx, cacheKey, message, time.Hour).Err() + } + return message, nil +} + +type captainWindowMetricValues struct { + handled, autoResolutionRate, handoffRate, hoursSaved, reopenRate, depth float64 +} + +func (s *CaptainAssistantService) captainWindowMetrics(ctx context.Context, accountID, assistantID uint, since, until time.Time) (captainWindowMetricValues, error) { + db := s.assistantRepo.DB().WithContext(ctx) + base := db.Model(&model.Message{}).Where("account_id = ? AND sender_id = ? AND sender_type IN ? AND created_at BETWEEN ? AND ?", accountID, assistantID, []string{"Captain::Assistant", "CaptainAssistant", "captain_assistant"}, since, until) + var handled int64 + if err := base.Distinct("conversation_id").Count(&handled).Error; err != nil { + return captainWindowMetricValues{}, err + } + var publicReplies int64 + if err := base.Where("message_type = ? AND private = ?", "outgoing", false).Count(&publicReplies).Error; err != nil { + return captainWindowMetricValues{}, err + } + var depthConversations int64 + if err := base.Where("message_type = ? AND private = ?", "outgoing", false).Distinct("conversation_id").Count(&depthConversations).Error; err != nil { + return captainWindowMetricValues{}, err + } + var handledIDs []uint + if err := base.Distinct().Pluck("conversation_id", &handledIDs).Error; err != nil { + return captainWindowMetricValues{}, err + } + resolved, handoffs, reopened := int64(0), int64(0), int64(0) + if len(handledIDs) > 0 { + resolvedEvents := s.captainResolvedEventQuery(ctx, accountID, handledIDs, since, until) + if err := resolvedEvents.Distinct("conversation_id").Count(&resolved).Error; err != nil { + return captainWindowMetricValues{}, err + } + if err := db.Model(&model.ReportingEvent{}).Where("account_id = ? AND conversation_id IN ? AND name IN ? AND created_at BETWEEN ? AND ?", accountID, handledIDs, captainHandoffEvents, since, until).Distinct("conversation_id").Count(&handoffs).Error; err != nil { + return captainWindowMetricValues{}, err + } + resolvedSubquery := resolvedEvents.Select("conversation_id, event_end_time") + if err := db.Table("reporting_events AS reopens").Joins("INNER JOIN (?) resolves ON resolves.conversation_id = reopens.conversation_id AND reopens.event_end_time >= resolves.event_end_time", resolvedSubquery). + Where("reopens.account_id = ? AND reopens.name = ? AND reopens.value > 0 AND reopens.event_end_time <= ?", accountID, "conversation_opened", until). + Distinct("reopens.conversation_id").Count(&reopened).Error; err != nil { + return captainWindowMetricValues{}, err + } + } + return captainWindowMetricValues{ + handled: float64(handled), autoResolutionRate: captainRate(resolved, handled), handoffRate: captainRate(handoffs, handled), + hoursSaved: math.Round(float64(publicReplies) * 120 / 3600), reopenRate: captainRate(reopened, resolved), + depth: captainDivide(publicReplies, depthConversations), + }, nil +} + +func (s *CaptainAssistantService) captainResolvedEventQuery(ctx context.Context, accountID uint, handledIDs []uint, since, until time.Time) *gorm.DB { + db := s.assistantRepo.DB().WithContext(ctx) + handoffIDs := db.Model(&model.ReportingEvent{}).Select("conversation_id").Where("account_id = ? AND name IN ? AND created_at BETWEEN ? AND ?", accountID, captainHandoffEvents, since, until) + return db.Model(&model.ReportingEvent{}). + Where("account_id = ? AND conversation_id IN ? AND name IN ? AND created_at BETWEEN ? AND ?", accountID, handledIDs, captainResolvedEvents, since, until). + Where("NOT (name = ? AND conversation_id IN (?))", "conversation_bot_resolved", handoffIDs) +} + +func (s *CaptainAssistantService) captainKnowledgeStats(ctx context.Context, assistantID uint) (CaptainKnowledgeStats, error) { + db := s.assistantRepo.DB().WithContext(ctx) + var approved, pending, documents int64 + if err := db.Model(&model.CaptainAssistantResponse{}).Where("assistant_id = ? AND status = ?", assistantID, model.ResponseStatusApproved).Count(&approved).Error; err != nil { + return CaptainKnowledgeStats{}, err + } + if err := db.Model(&model.CaptainAssistantResponse{}).Where("assistant_id = ? AND status = ?", assistantID, model.ResponseStatusPending).Count(&pending).Error; err != nil { + return CaptainKnowledgeStats{}, err + } + if err := db.Model(&model.CaptainDocument{}).Where("assistant_id = ?", assistantID).Count(&documents).Error; err != nil { + return CaptainKnowledgeStats{}, err + } + total := approved + pending + coverage := float64(0) + if total > 0 { + coverage = math.Round(float64(approved) / float64(total) * 100) + } + return CaptainKnowledgeStats{Approved: approved, Pending: pending, Documents: documents, Coverage: coverage}, nil +} + +func (s *CaptainAssistantService) Drilldown(ctx context.Context, accountID, assistantID uint, params CaptainDrilldownParams) (*CaptainDrilldownResult, error) { + supported := map[string]bool{"conversations_handled": true, "auto_resolution_rate": true, "handoff_rate": true, "reopen_rate": true} + if !supported[params.Metric] { + return nil, fmt.Errorf("unsupported metric") + } + if _, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, assistantID); err != nil { + return nil, err + } + since, until, _, _ := captainStatsWindows(params.Range, params.TimezoneOffset, time.Now()) + db := s.assistantRepo.DB().WithContext(ctx) + var handledIDs []uint + if err := db.Model(&model.Message{}).Where("account_id = ? AND sender_id = ? AND sender_type IN ? AND created_at BETWEEN ? AND ?", accountID, assistantID, []string{"Captain::Assistant", "CaptainAssistant", "captain_assistant"}, since, until).Distinct().Pluck("conversation_id", &handledIDs).Error; err != nil { + return nil, err + } + ids := handledIDs + if params.Metric != "conversations_handled" { + var eventIDs []uint + resolvedEvents := s.captainResolvedEventQuery(ctx, accountID, handledIDs, since, until) + query := resolvedEvents + conversationIDColumn := "conversation_id" + if params.Metric == "handoff_rate" { + query = db.Model(&model.ReportingEvent{}).Where("account_id = ? AND conversation_id IN ? AND name IN ? AND created_at BETWEEN ? AND ?", accountID, handledIDs, captainHandoffEvents, since, until) + } else if params.Metric == "reopen_rate" { + query = db.Table("reporting_events AS reopens").Joins("INNER JOIN (?) resolves ON resolves.conversation_id = reopens.conversation_id AND reopens.event_end_time >= resolves.event_end_time", resolvedEvents.Select("conversation_id, event_end_time")). + Where("reopens.account_id = ? AND reopens.name = ? AND reopens.value > 0 AND reopens.event_end_time <= ?", accountID, "conversation_opened", until) + conversationIDColumn = "reopens.conversation_id" + } + if err := query.Distinct().Pluck(conversationIDColumn, &eventIDs).Error; err != nil { + return nil, err + } + ids = eventIDs + } + params.Page = max(params.Page, 1) + if params.PerPage <= 0 { + params.PerPage = 25 + } + if params.PerPage > 100 { + params.PerPage = 100 + } + var total int64 + query := db.Model(&model.Conversation{}).Where("account_id = ? AND id IN ?", accountID, ids) + if err := query.Count(&total).Error; err != nil { + return nil, err + } + var conversations []model.Conversation + if err := query.Preload("Contact").Preload("Inbox").Preload("Assignee").Order("created_at DESC").Offset((params.Page - 1) * params.PerPage).Limit(params.PerPage).Find(&conversations).Error; err != nil { + return nil, err + } + payload := make([]map[string]any, 0, len(conversations)) + for i := range conversations { + payload = append(payload, s.captainConversationDrilldownRecord(ctx, &conversations[i])) + } + return &CaptainDrilldownResult{Meta: map[string]any{"metric": params.Metric, "current_page": params.Page, "per_page": params.PerPage, "total_count": total, "conversation_count": total, "range": map[string]any{"since": since.Unix(), "until": until.Unix()}}, Payload: payload}, nil +} + +func (s *CaptainAssistantService) captainConversationDrilldownRecord(ctx context.Context, conversation *model.Conversation) map[string]any { + db := s.assistantRepo.DB().WithContext(ctx) + var last model.Message + _ = db.Where("conversation_id = ? AND message_type <> ?", conversation.ID, "activity").Order("created_at DESC, id DESC").First(&last).Error + var lastPayload any + if last.ID != 0 { + lastPayload = map[string]any{"id": last.ID, "content": last.Content, "message_type": last.MessageType, "sender_name": nil, "created_at": last.CreatedAt.Unix()} + } + assigneeName := "" + if conversation.Assignee != nil { + assigneeName = conversation.Assignee.Name + } + return map[string]any{"record_type": "conversation", "conversation": map[string]any{ + "id": conversation.ID, "display_id": conversation.DisplayID, "contact_id": conversation.ContactID, "contact_name": conversation.Contact.Name, + "inbox_id": conversation.InboxID, "inbox_name": conversation.Inbox.Name, "assignee_id": conversation.AssigneeID, "assignee_name": assigneeName, + "status": conversation.Status, "created_at": conversation.CreatedAt.Unix(), "last_activity_at": valueOrZero(conversation.LastActivityAt), "last_message": lastPayload, + }, "message": nil, "metric_value": nil, "occurred_at": conversation.CreatedAt.Unix()} +} + +func (s *CaptainAssistantService) CreateMessageReport(ctx context.Context, accountID, userID, messageID uint, reason, description string) (*model.CaptainMessageReport, error) { + if !captainReportReasons[reason] { + return nil, fmt.Errorf("invalid report_reason") + } + var message model.Message + if err := s.assistantRepo.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, messageID).First(&message).Error; err != nil { + return nil, err + } + if message.SenderType != "Captain::Assistant" && message.SenderType != "CaptainAssistant" && message.SenderType != "captain_assistant" { + return nil, fmt.Errorf("Only Captain messages can be reported") + } + report := &model.CaptainMessageReport{AccountID: accountID, ConversationID: message.ConversationID, MessageID: message.ID, UserID: userID, ReportReason: reason, Description: description} + if err := s.assistantRepo.DB().WithContext(ctx).Create(report).Error; err != nil { + return nil, err + } + return report, nil +} + +func captainStatsWindows(rangeValue string, timezoneOffset float64, now time.Time) (time.Time, time.Time, time.Time, time.Time) { + allowed := map[string]bool{"7": true, "30": true, "90": true, "this_month": true, "last_month": true} + if !allowed[rangeValue] { + rangeValue = "30" + } + loc := time.FixedZone("captain", int(timezoneOffset*3600)) + localNow := now.In(loc) + if rangeValue == "this_month" || rangeValue == "last_month" { + start := time.Date(localNow.Year(), localNow.Month(), 1, 0, 0, 0, 0, loc) + end := localNow + if rangeValue == "last_month" { + end = start.Add(-time.Nanosecond) + start = time.Date(end.Year(), end.Month(), 1, 0, 0, 0, 0, loc) + } + previousEnd := start.Add(-time.Nanosecond) + previousStart := time.Date(previousEnd.Year(), previousEnd.Month(), 1, 0, 0, 0, 0, loc) + if rangeValue == "this_month" { + elapsed := end.Sub(start) + candidate := previousStart.Add(elapsed) + if candidate.Before(previousEnd) { + previousEnd = candidate + } + } + return start.UTC(), end.UTC(), previousStart.UTC(), previousEnd.UTC() + } + days, _ := strconv.Atoi(rangeValue) + duration := time.Duration(days) * 24 * time.Hour + return now.Add(-duration), now, now.Add(-2 * duration), now.Add(-duration) +} + +func captainRate(numerator, denominator int64) float64 { + if denominator == 0 { + return 0 + } + return math.Round(float64(numerator)/float64(denominator)*1000) / 10 +} +func captainDivide(numerator, denominator int64) float64 { + if denominator == 0 { + return 0 + } + return math.Round(float64(numerator)/float64(denominator)*10) / 10 +} +func captainPack(current, previous float64, percent bool) CaptainMetricValue { + trend := current - previous + if percent { + if previous == 0 { + trend = 0 + } else { + trend = (current - previous) / previous * 100 + } + } + return CaptainMetricValue{Current: current, Previous: previous, Trend: math.Round(trend*10) / 10} +} +func valueOrZero(value *int64) int64 { + if value == nil { + return 0 + } + return *value +} + // --- Config Management --- // GetConfig reads and parses the assistant's JSONB config. diff --git a/backend/internal/service/captain_conversation_service.go b/backend/internal/service/captain_conversation_service.go index 33af9e95..0206da65 100644 --- a/backend/internal/service/captain_conversation_service.go +++ b/backend/internal/service/captain_conversation_service.go @@ -192,7 +192,7 @@ func (s *CaptainConversationService) createCaptainOutgoingMessage(ctx context.Co ConversationID: conversation.ID, InboxID: conversation.InboxID, SenderID: &senderID, - SenderType: "CaptainAssistant", + SenderType: "Captain::Assistant", Content: content, ContentType: string(model.MessageContentTypeText), MessageType: string(model.MessageTypeOutgoing), diff --git a/backend/internal/service/captain_conversation_worker_test.go b/backend/internal/service/captain_conversation_worker_test.go index 8fa97487..bef27f9e 100644 --- a/backend/internal/service/captain_conversation_worker_test.go +++ b/backend/internal/service/captain_conversation_worker_test.go @@ -65,7 +65,7 @@ func TestCaptainConversationResponseJobQueuesFromIncomingMessage(t *testing.T) { var outgoing model.Message require.NoError(t, db.Where("conversation_id = ? AND message_type = ?", conversation.ID, model.MessageTypeOutgoing).First(&outgoing).Error) assert.Equal(t, assistant.ID, *outgoing.SenderID) - assert.Equal(t, "CaptainAssistant", outgoing.SenderType) + assert.Equal(t, "Captain::Assistant", outgoing.SenderType) assert.Equal(t, "Welcome to Captain", outgoing.Content) require.NoError(t, db.Model(&model.BackgroundJob{}).Where("job_type = ?", TaskTypeMessageSendReply).Count(&count).Error) diff --git a/backend/internal/service/category_service.go b/backend/internal/service/category_service.go index 0df2994a..6301a10a 100644 --- a/backend/internal/service/category_service.go +++ b/backend/internal/service/category_service.go @@ -26,6 +26,7 @@ type CreateCategoryRequest struct { Slug string `json:"slug" validate:"required"` Description string `json:"description"` Icon string `json:"icon"` + IconColor string `json:"icon_color"` Position int `json:"position"` Locale string `json:"locale"` ParentID *uint `json:"parent_id"` @@ -41,6 +42,7 @@ type UpdateCategoryRequest struct { Slug *string `json:"slug"` Description *string `json:"description"` Icon *string `json:"icon"` + IconColor *string `json:"icon_color"` Position *int `json:"position"` Locale *string `json:"locale"` ParentID *uint `json:"parent_id"` @@ -58,6 +60,7 @@ func (s *CategoryService) Create(ctx context.Context, portalID uint, accountID u Slug: req.Slug, Description: req.Description, Icon: req.Icon, + IconColor: req.IconColor, Position: req.Position, Locale: req.Locale, ParentID: firstCategoryParentID(req.ParentID, req.ParentCategoryID), @@ -121,6 +124,9 @@ func (s *CategoryService) Update(ctx context.Context, id uint, req *UpdateCatego if req.Icon != nil { category.Icon = *req.Icon } + if req.IconColor != nil { + category.IconColor = *req.IconColor + } if req.Position != nil { category.Position = *req.Position } @@ -175,6 +181,9 @@ func (s *CategoryService) UpdateExisting(ctx context.Context, category *model.Ca if req.Icon != nil { category.Icon = *req.Icon } + if req.IconColor != nil { + category.IconColor = *req.IconColor + } if req.Position != nil { category.Position = *req.Position } diff --git a/backend/internal/service/conversation_service.go b/backend/internal/service/conversation_service.go index 2dff5316..9693fbe2 100644 --- a/backend/internal/service/conversation_service.go +++ b/backend/internal/service/conversation_service.go @@ -406,7 +406,8 @@ func (s *ConversationService) validateSlaPolicy(ctx context.Context, accountID u // AssignAgentRequest is the DTO for assigning an agent to a conversation. type AssignAgentRequest struct { - AssigneeID uint `json:"assignee_id" validate:"required"` + AssigneeID uint `json:"assignee_id"` + AssigneeType string `json:"assignee_type,omitempty"` } // AssignAgent assigns a conversation to an agent. @@ -462,6 +463,7 @@ func (s *ConversationService) AssignAgent(ctx context.Context, accountID, id uin } conversation.AssigneeID = &assigneeID + conversation.AssigneeAgentBotID = nil // Dispatch EventConversationAssigned event := channel.NewChannelEvent(channel.EventConversationAssigned, channel.ChannelType(conversation.ChannelType), conversation.AccountID, conversation.InboxID) @@ -478,6 +480,40 @@ func (s *ConversationService) AssignAgent(ctx context.Context, accountID, id uin return conversation, nil } +// AssignAgentBot assigns a globally accessible or account-owned bot and clears +// the human assignee, matching Conversations::AssignmentService. +func (s *ConversationService) AssignAgentBot(ctx context.Context, accountID, id, agentBotID uint) (*model.Conversation, *model.AgentBot, error) { + conversation, err := s.repo.FindByAccountAndID(ctx, accountID, id) + if err != nil { + return nil, nil, err + } + if agentBotID == 0 { + if err := s.repo.AssignAgentBot(ctx, conversation.ID, 0); err != nil { + return nil, nil, err + } + conversation.AssigneeID = nil + conversation.AssigneeAgentBotID = nil + s.dispatchConversationEvent(ctx, channel.EventConversationUnassigned, conversation) + s.indexConversation(ctx, conversation) + return conversation, nil, nil + } + + var bot model.AgentBot + if err := s.repo.DB().WithContext(ctx). + Where("id = ? AND (account_id IS NULL OR account_id = ?)", agentBotID, accountID). + First(&bot).Error; err != nil { + return nil, nil, errors.New("agent bot not found") + } + if err := s.repo.AssignAgentBot(ctx, conversation.ID, bot.ID); err != nil { + return nil, nil, err + } + conversation.AssigneeID = nil + conversation.AssigneeAgentBotID = &bot.ID + s.dispatchConversationEvent(ctx, channel.EventConversationAssigned, conversation) + s.indexConversation(ctx, conversation) + return conversation, &bot, nil +} + // UnassignAgent removes the agent assignment from a conversation. // Convenience wrapper around AssignAgent(id, 0) — sends EventConversationUnassigned. // Reference: Chatwoot conversations_controller.rb #unassign diff --git a/backend/internal/service/enterprise_billing_worker.go b/backend/internal/service/enterprise_billing_worker.go new file mode 100644 index 00000000..dae95950 --- /dev/null +++ b/backend/internal/service/enterprise_billing_worker.go @@ -0,0 +1,343 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/gochat/gochat/internal/model" + "github.com/gochat/gochat/internal/worker" + "gorm.io/gorm" +) + +const TaskTypeEnterpriseCreateStripeCustomer = "enterprise:create_stripe_customer" + +type enterpriseCreateStripeCustomerJob struct { + AccountID uint `json:"account_id"` +} + +var enterpriseBillingRegistrations sync.Map + +func RegisterEnterpriseBillingJobs(wp *worker.WorkerPool, svc *AccountService) { + if wp == nil || svc == nil { + return + } + if _, loaded := enterpriseBillingRegistrations.LoadOrStore(wp, struct{}{}); loaded { + return + } + wp.Register(TaskTypeEnterpriseCreateStripeCustomer, svc.performCreateStripeCustomerJob) +} + +func (s *AccountService) performCreateStripeCustomerJob(ctx context.Context, job *model.BackgroundJob) error { + var payload enterpriseCreateStripeCustomerJob + if err := json.Unmarshal(job.Payload, &payload); err != nil || payload.AccountID == 0 { + return fmt.Errorf("invalid Stripe customer job payload") + } + defer s.clearEnterpriseCustomerCreationFlag(ctx, payload.AccountID) + return s.createStripeCustomer(ctx, payload.AccountID) +} + +func (s *AccountService) clearEnterpriseCustomerCreationFlag(ctx context.Context, accountID uint) { + db := s.repo.DB().WithContext(ctx) + if db.Dialector.Name() == "postgres" { + _ = db.Model(&model.Account{}).Where("id = ?", accountID).UpdateColumn("custom_attributes", gorm.Expr("custom_attributes - 'is_creating_customer'")).Error + return + } + var account model.Account + if db.First(&account, accountID).Error == nil { + attrs := account.CustomAttributesMap() + delete(attrs, "is_creating_customer") + if account.SetCustomAttributesMap(attrs) == nil { + _ = db.Model(&account).Update("custom_attributes", account.CustomAttributes).Error + } + } +} + +func (s *AccountService) createStripeCustomer(ctx context.Context, accountID uint) error { + secret := strings.TrimSpace(os.Getenv("STRIPE_SECRET_KEY")) + if secret == "" { + return fmt.Errorf("STRIPE_SECRET_KEY is not configured") + } + account, err := s.repo.FindByID(ctx, accountID) + if err != nil { + return err + } + plan, err := s.defaultCloudPlan(ctx) + if err != nil { + return err + } + currency := s.accountBillingCurrency(ctx, account) + priceID := cloudPlanPriceID(plan, currency) + if priceID == "" { + return fmt.Errorf("default cloud plan has no Stripe price") + } + attrs := account.CustomAttributesMap() + customerID := "" + if value := attrs["stripe_customer_id"]; value != nil { + customerID = strings.TrimSpace(fmt.Sprint(value)) + } + var subscription map[string]any + if customerID != "" { + subscription, err = s.stripeActiveSubscription(ctx, secret, customerID) + if err != nil { + return err + } + if subscription != nil && !cloudPlanContainsProduct(plan, stripeSubscriptionFields(subscription).productID) { + return nil + } + } + if customerID == "" { + customerID, err = s.stripeCreateCustomer(ctx, secret, account, currency) + if err != nil { + return err + } + } + if subscription == nil { + subscription, err = s.stripeCreateSubscription(ctx, secret, customerID, priceID, cloudPlanDefaultQuantity(plan)) + if err != nil { + return err + } + } + fields := stripeSubscriptionFields(subscription) + planName := strings.TrimSpace(fmt.Sprint(plan["name"])) + attrs["stripe_customer_id"] = customerID + attrs["stripe_price_id"] = fields.priceID + attrs["stripe_product_id"] = fields.productID + attrs["plan_name"] = planName + attrs["subscribed_quantity"] = fields.quantity + attrs["subscription_status"] = fields.status + attrs["billing_currency"] = supportedBillingCurrency(fields.currency) + if fields.periodEnd > 0 { + attrs["subscription_ends_on"] = time.Unix(fields.periodEnd, 0).UTC().Format(time.RFC3339) + } + delete(attrs, "is_creating_customer") + if err := account.SetCustomAttributesMap(attrs); err != nil { + return err + } + reconcileCloudPlanFeatures(account, planName, planName) + return s.repo.Update(ctx, account) +} + +func (s *AccountService) defaultCloudPlan(ctx context.Context) (map[string]any, error) { + var config model.InstallationConfig + if err := s.repo.DB().WithContext(ctx).Where("name = ?", "CHATWOOT_CLOUD_PLANS").First(&config).Error; err != nil { + return nil, err + } + var plans []map[string]any + if err := json.Unmarshal([]byte(config.Value), &plans); err != nil || len(plans) == 0 { + return nil, fmt.Errorf("CHATWOOT_CLOUD_PLANS is empty") + } + return plans[0], nil +} + +func cloudPlanPriceID(plan map[string]any, currency string) string { + raw := plan["price_ids"] + if values, ok := raw.([]any); ok { + return firstString(values) + } + byCurrency, _ := raw.(map[string]any) + for _, key := range []string{currency, "usd"} { + if value := firstStringValue(byCurrency[key]); value != "" { + return value + } + } + for _, value := range byCurrency { + if priceID := firstStringValue(value); priceID != "" { + return priceID + } + } + return "" +} + +func firstStringValue(value any) string { + if values, ok := value.([]any); ok { + return firstString(values) + } + return strings.TrimSpace(fmt.Sprint(value)) +} + +func firstString(values []any) string { + for _, value := range values { + if text := strings.TrimSpace(fmt.Sprint(value)); text != "" { + return text + } + } + return "" +} + +func cloudPlanDefaultQuantity(plan map[string]any) int { + if quantity := int(numberValue(plan["default_quantity"])); quantity > 0 { + return quantity + } + return 2 +} + +func cloudPlanContainsProduct(plan map[string]any, productID string) bool { + if values, ok := plan["product_id"].([]any); ok { + for _, value := range values { + if fmt.Sprint(value) == productID { + return true + } + } + return false + } + return strings.TrimSpace(fmt.Sprint(plan["product_id"])) == productID +} + +func (s *AccountService) stripeCreateCustomer(ctx context.Context, secret string, account *model.Account, currency string) (string, error) { + values := url.Values{"name": {account.Name}} + var admin model.User + _ = s.repo.DB().WithContext(ctx).Joins("JOIN account_users ON account_users.user_id = users.id").Where("account_users.account_id = ? AND account_users.role = ?", account.ID, "administrator").Order("account_users.id ASC").First(&admin).Error + values.Set("email", admin.Email) + if currency == "brl" { + values.Set("address[country]", "BR") + values.Set("preferred_locales[0]", "pt-BR") + } + response, err := stripeFormRequest(ctx, secret, http.MethodPost, "/v1/customers", values) + if err != nil { + return "", err + } + id := strings.TrimSpace(fmt.Sprint(response["id"])) + if id == "" { + return "", fmt.Errorf("Stripe customer response has no id") + } + return id, nil +} + +func (s *AccountService) stripeActiveSubscription(ctx context.Context, secret, customerID string) (map[string]any, error) { + response, err := stripeFormRequest(ctx, secret, http.MethodGet, "/v1/subscriptions", url.Values{"customer": {customerID}, "status": {"active"}, "limit": {"1"}}) + if err != nil { + return nil, err + } + data, _ := response["data"].([]any) + if len(data) == 0 { + return nil, nil + } + subscription, _ := data[0].(map[string]any) + return subscription, nil +} + +func (s *AccountService) stripeCreateSubscription(ctx context.Context, secret, customerID, priceID string, quantity int) (map[string]any, error) { + return stripeFormRequest(ctx, secret, http.MethodPost, "/v1/subscriptions", url.Values{"customer": {customerID}, "items[0][price]": {priceID}, "items[0][quantity]": {strconv.Itoa(quantity)}}) +} + +func stripeFormRequest(ctx context.Context, secret, method, path string, values url.Values) (map[string]any, error) { + base := strings.TrimRight(os.Getenv("STRIPE_API_BASE"), "/") + if base == "" { + base = "https://api.stripe.com" + } + endpoint := base + path + var body *strings.Reader + if method == http.MethodGet { + endpoint += "?" + values.Encode() + body = strings.NewReader("") + } else { + body = strings.NewReader(values.Encode()) + } + request, err := http.NewRequestWithContext(ctx, method, endpoint, body) + if err != nil { + return nil, err + } + request.SetBasicAuth(secret, "") + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response, err := http.DefaultClient.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + var payload map[string]any + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + return nil, err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf("Stripe API returned %d: %v", response.StatusCode, payload) + } + return payload, nil +} + +type stripeSubscriptionData struct { + priceID, productID, currency, status string + quantity int + periodEnd int64 +} + +func stripeSubscriptionFields(subscription map[string]any) stripeSubscriptionData { + result := stripeSubscriptionData{status: strings.TrimSpace(fmt.Sprint(subscription["status"])), quantity: int(numberValue(subscription["quantity"])), periodEnd: int64(numberValue(subscription["current_period_end"]))} + price, _ := subscription["plan"].(map[string]any) + if items, ok := subscription["items"].(map[string]any); ok { + if data, ok := items["data"].([]any); ok && len(data) > 0 { + item, _ := data[0].(map[string]any) + if result.quantity == 0 { + result.quantity = int(numberValue(item["quantity"])) + } + if result.periodEnd == 0 { + result.periodEnd = int64(numberValue(item["current_period_end"])) + } + if current, ok := item["price"].(map[string]any); ok { + price = current + } + } + } + result.priceID = strings.TrimSpace(fmt.Sprint(price["id"])) + result.productID = strings.TrimSpace(fmt.Sprint(price["product"])) + result.currency = strings.TrimSpace(fmt.Sprint(price["currency"])) + return result +} + +func numberValue(value any) float64 { + switch number := value.(type) { + case float64: + return number + case int: + return float64(number) + case int64: + return float64(number) + default: + parsed, _ := strconv.ParseFloat(fmt.Sprint(value), 64) + return parsed + } +} + +func supportedBillingCurrency(currency string) string { + currency = strings.ToLower(strings.TrimSpace(currency)) + if _, ok := supportedBillingCurrencies[currency]; ok { + return currency + } + return "usd" +} + +func reconcileCloudPlanFeatures(account *model.Account, planName, defaultPlanName string) { + flags := map[string]bool{} + _ = json.Unmarshal([]byte(account.FeatureFlags), &flags) + startup := []string{"inbound_emails", "help_center", "campaigns", "team_management", "channel_facebook", "channel_email", "channel_instagram", "channel_tiktok", "captain_integration", "captain_document_auto_sync", "advanced_search_indexing", "advanced_search", "linear_integration", "channel_voice"} + business := []string{"sla", "custom_roles", "csat_review_notes", "conversation_required_attributes", "advanced_assignment", "custom_tools", "companies"} + enterprise := []string{"audit_logs", "disable_branding", "saml"} + for _, feature := range append(append(append([]string{}, startup...), business...), enterprise...) { + flags[feature] = false + } + flags["captain_integration_v2"] = false + if planName != defaultPlanName { + for _, feature := range startup { + flags[feature] = true + } + if planName == "Business" || planName == "Enterprise" { + for _, feature := range business { + flags[feature] = true + } + } + if planName == "Enterprise" { + for _, feature := range enterprise { + flags[feature] = true + } + } + } + encoded, _ := json.Marshal(flags) + account.FeatureFlags = string(encoded) +} diff --git a/backend/internal/service/enterprise_billing_worker_test.go b/backend/internal/service/enterprise_billing_worker_test.go new file mode 100644 index 00000000..9f6484b9 --- /dev/null +++ b/backend/internal/service/enterprise_billing_worker_test.go @@ -0,0 +1,130 @@ +package service + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "testing" + + "github.com/gochat/gochat/internal/model" + "github.com/gochat/gochat/internal/repository" + "github.com/gochat/gochat/internal/worker" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestEnterpriseBillingWorkerCreatesBRLCustomerAndSubscription(t *testing.T) { + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=private", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{}, &model.InstallationConfig{}, &model.BackgroundJob{})) + t.Cleanup(func() { + sqlDB, _ := db.DB() + _ = sqlDB.Close() + }) + + account := &model.Account{Name: "Acme Brasil", Locale: "pt_BR", Active: true, FeatureFlags: `{"sla":true}`} + admin := &model.User{Name: "Admin", Email: "admin@example.com", Password: "hashed", Active: true} + require.NoError(t, db.Create(account).Error) + require.NoError(t, db.Create(admin).Error) + require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: admin.ID, Role: "administrator"}).Error) + require.NoError(t, db.Create(&model.InstallationConfig{Name: "ENABLE_MULTI_CURRENCY_BILLING", Value: "true"}).Error) + require.NoError(t, db.Create(&model.InstallationConfig{ + Name: "CHATWOOT_CLOUD_PLANS", + Value: `[{"name":"Hacker","product_id":"prod_default","price_ids":{"usd":["price_usd"],"brl":["price_brl"]},"default_quantity":2}]`, + }).Error) + + var mu sync.Mutex + requests := map[string]url.Values{} + stripe := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, readErr := io.ReadAll(r.Body) + require.NoError(t, readErr) + values, parseErr := url.ParseQuery(string(body)) + require.NoError(t, parseErr) + mu.Lock() + requests[r.URL.Path] = values + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v1/customers": + _, _ = w.Write([]byte(`{"id":"cus_test"}`)) + case "/v1/subscriptions": + _, _ = w.Write([]byte(`{"status":"active","items":{"data":[{"quantity":2,"current_period_end":1780000000,"price":{"id":"price_brl","product":"prod_default","currency":"brl"}}]}}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(stripe.Close) + t.Setenv("STRIPE_SECRET_KEY", "sk_test") + t.Setenv("STRIPE_API_BASE", stripe.URL) + + svc := NewAccountService(repository.NewAccountRepo(db)) + wp := worker.NewWorkerPool(db) + svc.SetWorkerPool(wp) + require.NoError(t, svc.SelectBillingCurrency(context.Background(), admin.ID, account.ID, "brl")) + + var job model.BackgroundJob + require.NoError(t, db.Where("job_type = ?", TaskTypeEnterpriseCreateStripeCustomer).First(&job).Error) + require.Equal(t, model.BackgroundJobStatusQueued, job.Status) + require.Equal(t, "default", job.Queue) + require.Equal(t, 3, job.MaxAttempts) + require.Equal(t, fmt.Sprintf("stripe-customer:%d", account.ID), job.IdempotencyKey) + + processed, err := wp.ProcessOne(context.Background()) + require.NoError(t, err) + require.True(t, processed) + require.NoError(t, db.First(account, account.ID).Error) + attrs := account.CustomAttributesMap() + require.NotContains(t, attrs, "is_creating_customer") + require.Equal(t, "cus_test", attrs["stripe_customer_id"]) + require.Equal(t, "price_brl", attrs["stripe_price_id"]) + require.Equal(t, "prod_default", attrs["stripe_product_id"]) + require.Equal(t, "Hacker", attrs["plan_name"]) + require.Equal(t, float64(2), attrs["subscribed_quantity"]) + require.Equal(t, "active", attrs["subscription_status"]) + require.Equal(t, "2026-05-28T20:26:40Z", attrs["subscription_ends_on"]) + require.Equal(t, "brl", attrs["billing_currency"]) + + mu.Lock() + customerForm := requests["/v1/customers"] + subscriptionForm := requests["/v1/subscriptions"] + mu.Unlock() + require.Equal(t, "Acme Brasil", customerForm.Get("name")) + require.Equal(t, "admin@example.com", customerForm.Get("email")) + require.Equal(t, "BR", customerForm.Get("address[country]")) + require.Equal(t, "pt-BR", customerForm.Get("preferred_locales[0]")) + require.Equal(t, "cus_test", subscriptionForm.Get("customer")) + require.Equal(t, "price_brl", subscriptionForm.Get("items[0][price]")) + require.Equal(t, "2", subscriptionForm.Get("items[0][quantity]")) +} + +func TestEnterpriseBillingWorkerAlwaysClearsCreationFlagOnFailure(t *testing.T) { + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=private", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{}, &model.InstallationConfig{}, &model.BackgroundJob{})) + t.Cleanup(func() { + sqlDB, _ := db.DB() + _ = sqlDB.Close() + }) + + account := &model.Account{Name: "Acme", Locale: "en", Active: true} + admin := &model.User{Name: "Admin", Email: "admin@example.com", Password: "hashed", Active: true} + require.NoError(t, db.Create(account).Error) + require.NoError(t, db.Create(admin).Error) + require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: admin.ID, Role: "administrator"}).Error) + + svc := NewAccountService(repository.NewAccountRepo(db)) + wp := worker.NewWorkerPool(db) + svc.SetWorkerPool(wp) + require.NoError(t, svc.EnsureEnterpriseAccountCustomerCreationFlag(context.Background(), account.ID, admin.ID)) + + processed, err := wp.ProcessOne(context.Background()) + require.ErrorContains(t, err, "STRIPE_SECRET_KEY is not configured") + require.True(t, processed) + require.NoError(t, db.First(account, account.ID).Error) + require.NotContains(t, account.CustomAttributesMap(), "is_creating_customer") +} diff --git a/backend/internal/service/inbox_service.go b/backend/internal/service/inbox_service.go index 974cbaba..a266aedd 100644 --- a/backend/internal/service/inbox_service.go +++ b/backend/internal/service/inbox_service.go @@ -28,6 +28,7 @@ const InboxTemplateSyncInitiatedMessage = "Template sync initiated successfully" const InboxTemplateSyncWhatsAppOnlyMessage = "Template sync is only available for WhatsApp channels" const InboxWhatsAppCallingUnsupportedMessage = "Inbox does not support WhatsApp calling" const InboxWhatsAppCallingFeatureRequiredMessage = "WhatsApp calling requires the channel_voice feature" +const InboxInboundCallsUnsupportedMessage = "Inbox does not support calling" const TaskTypeInboxSyncTemplates = "inbox:sync_templates" var ErrInboxLimitExceeded = errors.New(InboxLimitExceededMessage) @@ -35,6 +36,7 @@ var ErrInboxHealthWhatsAppCloudOnly = errors.New(InboxHealthWhatsAppCloudOnlyMes var ErrInboxTemplateSyncWhatsAppOnly = errors.New(InboxTemplateSyncWhatsAppOnlyMessage) var ErrInboxWhatsAppCallingUnsupported = errors.New(InboxWhatsAppCallingUnsupportedMessage) var ErrInboxWhatsAppCallingFeatureRequired = errors.New(InboxWhatsAppCallingFeatureRequiredMessage) +var ErrInboxInboundCallsUnsupported = errors.New(InboxInboundCallsUnsupportedMessage) type WhatsAppChannelService interface { FetchMessageTemplates(ctx context.Context, channel *channelmodel.ChannelWhatsApp) ([]interface{}, error) @@ -1904,6 +1906,47 @@ func (s *InboxService) DisableWhatsAppCalling(ctx context.Context, accountID, in return nil } +func (s *InboxService) SetInboundCalls(ctx context.Context, accountID, inboxID uint, enabled bool) error { + inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID) + if err != nil { + return fmt.Errorf("inbox not found: %w", err) + } + config := parseJSONMap(inbox.ChannelConfig) + channelType := strings.ToLower(inbox.ChannelType) + if channelType != "whatsapp" && channelType != "twilio_sms" && channelType != "twilio" { + return ErrInboxInboundCallsUnsupported + } + if !boolFromAny(config["voice_enabled"]) { + return ErrInboxInboundCallsUnsupported + } + config["inbound_calls_enabled"] = enabled + inbox.ChannelConfig = marshalInboxJSON(config) + return s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if channelType == "whatsapp" { + var channel channelmodel.ChannelWhatsApp + if err := tx.Where("account_id = ? AND inbox_id = ?", accountID, inboxID).First(&channel).Error; err == nil { + providerConfig := parseJSONMap(channel.ProviderConfig) + providerConfig["inbound_calls_enabled"] = enabled + if err := tx.Model(&channel).Update("provider_config", marshalInboxJSON(providerConfig)).Error; err != nil { return err } + } else if !errors.Is(err, gorm.ErrRecordNotFound) { return err } + } + return tx.Save(inbox).Error + }) +} + +func boolFromAny(value any) bool { + switch typed := value.(type) { + case bool: + return typed + case string: + return typed == "true" || typed == "1" + case float64: + return typed != 0 + default: + return false + } +} + func (s *InboxService) whatsAppCallingPrereqs(ctx context.Context, accountID, inboxID uint) (*model.Account, *model.Inbox, *channelmodel.ChannelWhatsApp, error) { inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID) if err != nil { diff --git a/backend/internal/service/inbox_service_test.go b/backend/internal/service/inbox_service_test.go index 04430a08..7b685e52 100644 --- a/backend/internal/service/inbox_service_test.go +++ b/backend/internal/service/inbox_service_test.go @@ -659,6 +659,31 @@ func TestInboxService_DisableWhatsAppCalling_PersistsFalseAndIgnoresWebhookFailu assert.Equal(t, false, providerConfig["calling_enabled"]) } +func TestInboxService_SetInboundCalls_PersistsVoiceInboxSetting(t *testing.T) { + svc, db := setupInboxServiceTest(t) + account, inbox, channel := createWhatsAppInboxTestPrereqs(t, db, "whatsapp_cloud") + inbox.ChannelConfig = `{"voice_enabled":true,"inbound_calls_enabled":true}` + require.NoError(t, db.Save(inbox).Error) + require.NoError(t, svc.SetInboundCalls(context.Background(), account.ID, inbox.ID, false)) + var updated model.Inbox + require.NoError(t, db.First(&updated, inbox.ID).Error) + assert.Equal(t, false, parseJSONMap(updated.ChannelConfig)["inbound_calls_enabled"]) + var updatedChannel channelmodel.ChannelWhatsApp; require.NoError(t, db.First(&updatedChannel, channel.ID).Error); assert.Equal(t, false, parseJSONMap(updatedChannel.ProviderConfig)["inbound_calls_enabled"]) + require.NoError(t, svc.SetInboundCalls(context.Background(), account.ID, inbox.ID, true)) + require.NoError(t, db.First(&updated, inbox.ID).Error) + assert.Equal(t, true, parseJSONMap(updated.ChannelConfig)["inbound_calls_enabled"]) +} + +func TestInboxService_SetInboundCalls_RejectsUnsupportedInbox(t *testing.T) { + svc, db := setupInboxServiceTest(t) + account, inbox := createInboxTestPrereqs(t, db, "web_widget") + require.ErrorIs(t, svc.SetInboundCalls(context.Background(), account.ID, inbox.ID, true), ErrInboxInboundCallsUnsupported) + inbox.ChannelType = "whatsapp" + inbox.ChannelConfig = `{}` + require.NoError(t, db.Save(inbox).Error) + require.ErrorIs(t, svc.SetInboundCalls(context.Background(), account.ID, inbox.ID, true), ErrInboxInboundCallsUnsupported) +} + func TestIsFakeChannelAllowed(t *testing.T) { t.Run("non-production environments allow fake channels", func(t *testing.T) { t.Setenv("GOCHAT_ENV", "development") diff --git a/backend/internal/service/profile_service.go b/backend/internal/service/profile_service.go index 762f6e38..f5394e4e 100644 --- a/backend/internal/service/profile_service.go +++ b/backend/internal/service/profile_service.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/gochat/gochat/internal/auth" "gorm.io/datatypes" "gorm.io/gorm" @@ -29,6 +30,7 @@ type ProfileService struct { accessTokenRepo *repository.AccessTokenRepo installationConfigRepo *repository.InstallationConfigRepo confirmationMailer ProfileConfirmationMailer + refreshTokenStore *auth.RefreshTokenStore } // NewProfileService creates a new Profile service. @@ -42,6 +44,8 @@ func NewProfileService(userRepo *repository.UserRepo, accountUserRepo *repositor svc.installationConfigRepo = repo case ProfileConfirmationMailer: svc.confirmationMailer = repo + case *auth.RefreshTokenStore: + svc.refreshTokenStore = repo } } return svc @@ -51,6 +55,50 @@ func (s *ProfileService) SetConfirmationMailer(mailer ProfileConfirmationMailer) s.confirmationMailer = mailer } +func (s *ProfileService) ListUserSessions(ctx context.Context, userID uint) ([]model.UserSession, error) { + if s == nil || s.userRepo == nil || s.userRepo.DB() == nil { + return nil, errors.New("profile session service unavailable") + } + var sessions []model.UserSession + err := s.userRepo.DB().WithContext(ctx). + Where("user_id = ?", userID). + Order("last_activity_at DESC NULLS LAST, created_at DESC"). + Find(&sessions).Error + if err != nil || s.refreshTokenStore == nil { + return sessions, err + } + active := sessions[:0] + for i := range sessions { + ok, tokenErr := s.refreshTokenStore.HasClient(ctx, userID, sessions[i].ClientID) + if tokenErr != nil { + return nil, tokenErr + } + if ok { + active = append(active, sessions[i]) + } + } + return active, nil +} + +func (s *ProfileService) RevokeUserSession(ctx context.Context, userID, sessionID uint, currentClientID string) error { + if s == nil || s.userRepo == nil || s.userRepo.DB() == nil { + return errors.New("profile session service unavailable") + } + var session model.UserSession + if err := s.userRepo.DB().WithContext(ctx).Where("id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil { + return err + } + if session.ClientID == currentClientID { + return errors.New("You cannot revoke your current session") + } + if s.refreshTokenStore != nil { + if err := s.refreshTokenStore.RevokeClient(ctx, userID, session.ClientID); err != nil { + return err + } + } + return s.userRepo.DB().WithContext(ctx).Delete(&session).Error +} + // ProfileUserResponse matches Chatwoot app/views/api/v1/models/_user.json.jbuilder. type ProfileUserResponse struct { AccessToken string `json:"access_token"` diff --git a/backend/internal/service/rbac_service.go b/backend/internal/service/rbac_service.go index 018ce10d..27a70458 100644 --- a/backend/internal/service/rbac_service.go +++ b/backend/internal/service/rbac_service.go @@ -488,20 +488,13 @@ func generateAPIKey() (string, error) { // so it can be used with AccountScopeWithService middleware. // Reference: middleware/account_scope.go — RBACLookup interface -// GetAccountUserRole returns a middleware.AccountUserRole DTO from the AccountUser model. -// This implements the middleware.RBACLookup interface. -func (s *RBACService) GetAccountUserRole(userID, accountID uint) (*AccountUserRole, error) { +// GetAccountUserRole implements middleware.RBACLookup. +func (s *RBACService) GetAccountUserRole(userID, accountID uint) (string, uint, error) { au, err := s.GetAccountUser(userID, accountID) if err != nil { - return nil, err + return "", 0, err } - return &AccountUserRole{ - UserID: au.UserID, - AccountID: au.AccountID, - Role: au.Role, - CustomRoleID: au.CustomRoleID, - Availability: au.Availability, - }, nil + return au.Role, au.CustomRoleID, nil } // GetCustomRolePermissions returns the auth.PermissionMatrixMap for a custom role. @@ -509,15 +502,3 @@ func (s *RBACService) GetAccountUserRole(userID, accountID uint) (*AccountUserRo func (s *RBACService) GetCustomRolePermissions(customRoleID uint) (auth.PermissionMatrixMap, error) { return s.GetCustomRolePermissionMatrix(customRoleID) } - -// AccountUserRole is a DTO that mirrors middleware.AccountUserRole. -// Both have the same fields; the service populates this from model.AccountUser -// and middleware reads it. We define it here so the service can return it -// without importing the middleware package (which would create a cycle). -type AccountUserRole struct { - UserID uint - AccountID uint - Role string - CustomRoleID uint - Availability string -} diff --git a/backend/internal/service/team_service.go b/backend/internal/service/team_service.go index 488d66c3..a28ea093 100644 --- a/backend/internal/service/team_service.go +++ b/backend/internal/service/team_service.go @@ -39,14 +39,18 @@ type CreateTeamRequest struct { Description string `json:"description,omitempty"` AllowAutoAssign *bool `json:"allow_auto_assign,omitempty"` AllowAutoAssignment *bool `json:"allow_auto_assignment,omitempty"` + Icon string `json:"icon,omitempty"` + IconColor string `json:"icon_color,omitempty"` } // UpdateTeamRequest is the DTO for updating a team. type UpdateTeamRequest struct { - Name string `json:"name,omitempty" validate:"omitempty,min=2"` - Description string `json:"description,omitempty"` - AllowAutoAssign *bool `json:"allow_auto_assign,omitempty"` - AllowAutoAssignment *bool `json:"allow_auto_assignment,omitempty"` + Name string `json:"name,omitempty" validate:"omitempty,min=2"` + Description string `json:"description,omitempty"` + AllowAutoAssign *bool `json:"allow_auto_assign,omitempty"` + AllowAutoAssignment *bool `json:"allow_auto_assignment,omitempty"` + Icon *string `json:"icon,omitempty"` + IconColor *string `json:"icon_color,omitempty"` } // TeamMemberRequest is the DTO for adding/removing team members. @@ -86,6 +90,8 @@ func (s *TeamService) Create(ctx context.Context, accountID uint, req CreateTeam Name: req.Name, Description: req.Description, AllowAutoAssignment: autoAssign, + Icon: req.Icon, + IconColor: req.IconColor, } if err := s.teamRepo.Create(ctx, team); err != nil { @@ -122,6 +128,12 @@ func (s *TeamService) Update(ctx context.Context, id, accountID uint, req Update } else if req.AllowAutoAssignment != nil { team.AllowAutoAssignment = *req.AllowAutoAssignment } + if req.Icon != nil { + team.Icon = *req.Icon + } + if req.IconColor != nil { + team.IconColor = *req.IconColor + } if err := s.teamRepo.Update(ctx, team); err != nil { applogger.L().Errorf("failed to update team: %v", err) diff --git a/backend/internal/service/whatsapp_call_service.go b/backend/internal/service/whatsapp_call_service.go index fd3b5bd2..e4838972 100644 --- a/backend/internal/service/whatsapp_call_service.go +++ b/backend/internal/service/whatsapp_call_service.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "strings" "time" @@ -156,6 +157,80 @@ type WhatsAppCallInitiateResult struct { PermissionMessage string } +type AccountCallListFilter struct { + Page int + UserID uint + CustomRoleID uint + AccountWide bool + Status string + Direction string + InboxID uint + AgentID uint + Since *time.Time + Until *time.Time +} + +type AccountCallListResult struct { + Calls []model.Call + Count int64 + Page int + TotalPages int +} + +// ListAccountCalls implements the account-wide CallFinder contract used by the +// Chatwoot calls dashboard. Visibility is already constrained by AccountScope; +// role-specific conversation visibility remains enforced by the dashboard routes. +func (s *WhatsAppCallService) ListAccountCalls(ctx context.Context, accountID uint, filter AccountCallListFilter) (*AccountCallListResult, error) { + const perPage = 25 + if filter.Page < 1 { + filter.Page = 1 + } + db := s.repo.DB().WithContext(ctx).Model(&model.Call{}).Where("account_id = ?", accountID) + if !filter.AccountWide && filter.CustomRoleID != 0 { + var role model.CustomRole + if err := s.repo.DB().WithContext(ctx).Where("id = ? AND account_id = ?", filter.CustomRoleID, accountID).First(&role).Error; err == nil { + keys, _ := role.GetPermissionKeys() + filter.AccountWide = slices.Contains(keys, model.DimensionReportManage) + } + } + if !filter.AccountWide { + accessibleConversations := s.repo.DB().WithContext(ctx).Model(&model.Conversation{}). + Select("conversations.id"). + Where("conversations.account_id = ?", accountID). + Where("conversations.inbox_id IN (?)", s.repo.DB().WithContext(ctx).Model(&model.InboxMember{}).Select("inbox_id").Where("user_id = ?", filter.UserID)) + db = db.Where("accepted_by_agent_id = ? AND conversation_id IN (?)", filter.UserID, accessibleConversations) + } + if filter.Status != "" { + db = db.Where("status = ?", strings.ReplaceAll(filter.Status, "-", "_")) + } + if filter.Direction != "" { + direction := map[string]string{"inbound": "incoming", "outbound": "outgoing"}[filter.Direction] + if direction == "" { + direction = filter.Direction + } + db = db.Where("direction = ?", direction) + } + if filter.InboxID != 0 { + db = db.Where("inbox_id = ?", filter.InboxID) + } + if filter.AgentID != 0 { + db = db.Where("accepted_by_agent_id = ?", filter.AgentID) + } + if filter.Since != nil && filter.Until != nil { + db = db.Where("created_at BETWEEN ? AND ?", *filter.Since, *filter.Until) + } + var count int64 + if err := db.Count(&count).Error; err != nil { + return nil, err + } + var calls []model.Call + if err := db.Preload("Conversation").Preload("Inbox").Preload("Contact").Preload("AcceptedByAgent"). + Order("created_at DESC").Offset((filter.Page - 1) * perPage).Limit(perPage).Find(&calls).Error; err != nil { + return nil, err + } + return &AccountCallListResult{Calls: calls, Count: count, Page: filter.Page, TotalPages: int((count + perPage - 1) / perPage)}, nil +} + // Initiate creates an outbound WhatsApp Call and linked voice_call message. // Reference: Enterprise WhatsappCallsController#initiate. func (s *WhatsAppCallService) Initiate(ctx context.Context, accountID uint, req WhatsAppCallInitiateRequest) (*WhatsAppCallInitiateResult, error) { diff --git a/backend/migrations/000057_add_chatwoot_4_15_parity_fields.down.sql b/backend/migrations/000057_add_chatwoot_4_15_parity_fields.down.sql new file mode 100644 index 00000000..109a5346 --- /dev/null +++ b/backend/migrations/000057_add_chatwoot_4_15_parity_fields.down.sql @@ -0,0 +1,15 @@ +DROP TABLE IF EXISTS captain_message_reports; +DROP TABLE IF EXISTS user_sessions; + +ALTER TABLE calls + DROP COLUMN IF EXISTS transcript; + +ALTER TABLE assignment_policies + DROP COLUMN IF EXISTS exclude_older_than_hours; + +ALTER TABLE teams + DROP COLUMN IF EXISTS icon_color, + DROP COLUMN IF EXISTS icon; + +ALTER TABLE categories + DROP COLUMN IF EXISTS icon_color; diff --git a/backend/migrations/000057_add_chatwoot_4_15_parity_fields.up.sql b/backend/migrations/000057_add_chatwoot_4_15_parity_fields.up.sql new file mode 100644 index 00000000..87295d7a --- /dev/null +++ b/backend/migrations/000057_add_chatwoot_4_15_parity_fields.up.sql @@ -0,0 +1,55 @@ +ALTER TABLE categories + ADD COLUMN IF NOT EXISTS icon_color VARCHAR(255) NOT NULL DEFAULT ''; + +ALTER TABLE teams + ADD COLUMN IF NOT EXISTS icon VARCHAR(255) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS icon_color VARCHAR(255) NOT NULL DEFAULT ''; + +ALTER TABLE assignment_policies + ADD COLUMN IF NOT EXISTS exclude_older_than_hours INTEGER DEFAULT 168; + +ALTER TABLE calls + ADD COLUMN IF NOT EXISTS transcript TEXT; + +UPDATE messages +SET sender_type = 'Captain::Assistant' +WHERE sender_type IN ('CaptainAssistant', 'captain_assistant'); + +CREATE TABLE IF NOT EXISTS user_sessions ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + client_id VARCHAR(255) NOT NULL, + ip_address VARCHAR(255), + user_agent TEXT, + browser_name VARCHAR(255), + browser_version VARCHAR(255), + device_name VARCHAR(255), + platform_name VARCHAR(255), + platform_version VARCHAR(255), + city VARCHAR(255), + country VARCHAR(255), + country_code VARCHAR(32), + last_activity_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id, client_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id); + +CREATE TABLE IF NOT EXISTS captain_message_reports ( + id BIGSERIAL PRIMARY KEY, + account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + conversation_id BIGINT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + message_id BIGINT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + report_reason VARCHAR(255) NOT NULL, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_captain_message_reports_account_id ON captain_message_reports(account_id); +CREATE INDEX IF NOT EXISTS idx_captain_message_reports_conversation_id ON captain_message_reports(conversation_id); +CREATE INDEX IF NOT EXISTS idx_captain_message_reports_message_id ON captain_message_reports(message_id); +CREATE INDEX IF NOT EXISTS idx_captain_message_reports_user_id ON captain_message_reports(user_id); diff --git a/backend/migrations/000058_align_category_slug_uniqueness.down.sql b/backend/migrations/000058_align_category_slug_uniqueness.down.sql new file mode 100644 index 00000000..59368af3 --- /dev/null +++ b/backend/migrations/000058_align_category_slug_uniqueness.down.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS index_categories_on_slug_and_locale_and_portal_id; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_categories_slug + ON categories(slug) + WHERE deleted_at IS NULL; diff --git a/backend/migrations/000058_align_category_slug_uniqueness.up.sql b/backend/migrations/000058_align_category_slug_uniqueness.up.sql new file mode 100644 index 00000000..7ac4cef4 --- /dev/null +++ b/backend/migrations/000058_align_category_slug_uniqueness.up.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS idx_categories_slug; + +CREATE UNIQUE INDEX IF NOT EXISTS index_categories_on_slug_and_locale_and_portal_id + ON categories(slug, locale, portal_id) + WHERE deleted_at IS NULL; diff --git a/backend/scripts/parity_frontend_browser_smoke.mjs b/backend/scripts/parity_frontend_browser_smoke.mjs index 3692a32f..f8524081 100644 --- a/backend/scripts/parity_frontend_browser_smoke.mjs +++ b/backend/scripts/parity_frontend_browser_smoke.mjs @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; const root = process.env.GOCHAT_ROOT || process.cwd(); -const chatwootDir = process.env.CHATWOOT_DIR || path.join(root, 'frontend'); +const chatwootDir = process.env.CHATWOOT_DIR || path.join(root, '..', 'docs', 'chatwoot'); const logDir = process.env.GOCHAT_SMOKE_LOG_DIR || path.join(root, '.tmp/frontend-smoke'); const apiHost = process.env.GOCHAT_SMOKE_API_HOST || '127.0.0.1'; const apiPort = process.env.GOCHAT_SMOKE_API_PORT || '3000'; @@ -47,6 +47,8 @@ function smokeHTML(entrypoint, route) { enabledLanguages: [{ iso_639_1_code: 'en', name: 'English' }], helpUrls: {}, selectedLocale: 'en', + isEnterprise: 'true', + enterprisePlanName: 'enterprise', }; const globalConfig = { INSTALLATION_NAME: 'GoChat', @@ -59,6 +61,8 @@ function smokeHTML(entrypoint, route) { MAXIMUM_FILE_UPLOAD_SIZE: '40', ACTIVE_PLATFORM_BANNERS: [], LOGOUT_REDIRECT_LINK: '/app/login', + DEPLOYMENT_ENV: 'cloud', + IS_ENTERPRISE: 'true', }; return ` @@ -159,6 +163,7 @@ function startSmokeShellServer() { } if ( requestURL.pathname.startsWith('/api/') || + requestURL.pathname.startsWith('/enterprise/') || requestURL.pathname.startsWith('/public/') || requestURL.pathname.startsWith('/auth/') || requestURL.pathname.startsWith('/rails/') @@ -264,7 +269,10 @@ const enterprisePages = [ 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`], + requests: [ + `/api/v1/accounts/${seed.account_id}/notification_settings`, + '/api/v1/profile/sessions', + ], }, { label: 'agent capacity screen', @@ -285,10 +293,28 @@ const enterprisePages = [ requests: ['/captain/preferences'], }, { - label: 'Captain assistants screen', - name: 'gochat-smoke-enterprise-captain-assistants', - route: `/app/accounts/${seed.account_id}/captain/captain_assistants_responses_index`, - requests: ['/captain/assistants'], + label: 'Captain assistant overview screen', + name: 'gochat-smoke-enterprise-captain-overview', + route: `/app/accounts/${seed.account_id}/captain/${seed.captain_assistant_id}/overview`, + requests: [ + `/captain/assistants/${seed.captain_assistant_id}/stats`, + `/captain/assistants/${seed.captain_assistant_id}/summary`, + ], + }, + { + label: 'billing screen', + name: 'gochat-smoke-enterprise-billing', + route: `/app/accounts/${seed.account_id}/settings/billing`, + requests: [ + `/enterprise/api/v1/accounts/${seed.account_id}/subscription`, + `/enterprise/api/v1/accounts/${seed.account_id}/limits`, + ], + }, + { + label: 'voice inbox settings screen', + name: 'gochat-smoke-enterprise-voice-inbox', + route: `/app/accounts/${seed.account_id}/settings/inboxes/${seed.voice_inbox_id}/voice-configuration`, + requests: [`/api/v1/accounts/${seed.account_id}/inboxes`], }, ].map(page => ({ ...page, @@ -451,6 +477,7 @@ class CDPPage { const isBackendAPI = request.url.includes(apiBaseURL); const isShellProxiedAPI = request.url.startsWith(frontendBaseURL) && ( request.url.includes('/api/') || + request.url.includes('/enterprise/') || request.url.includes('/public/') || request.url.includes('/auth/') || request.url.includes('/rails/') @@ -541,7 +568,9 @@ async function main() { for (const request of enterprisePage.requests) { await page.waitForCapturedRequestAfter(request, requestIndex, `${enterprisePage.label} requests ${request}`); } + page.assertNoFailedAPIRequests(requestIndex); } + const copilotRequestIndex = report.requests.length; await page.eval(`fetch(${JSON.stringify(`${apiBaseURL}/api/v1/accounts/${seed.account_id}/captain/copilot_threads`)}, { method: 'POST', headers: (() => { @@ -557,7 +586,8 @@ async function main() { })(), 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.waitForCapturedRequest('/captain/copilot_threads', 'browser context requests Copilot threads'); + await page.waitForCapturedRequestAfter('/captain/copilot_threads', copilotRequestIndex, 'browser context requests Copilot threads'); + page.assertNoFailedAPIRequests(copilotRequestIndex); } report.finished_at = new Date().toISOString(); report.status = 'passed'; diff --git a/backend/scripts/parity_frontend_smoke.sh b/backend/scripts/parity_frontend_smoke.sh index 6600c163..20d1f034 100755 --- a/backend/scripts/parity_frontend_smoke.sh +++ b/backend/scripts/parity_frontend_smoke.sh @@ -4,9 +4,9 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -CHATWOOT_DIR="${CHATWOOT_DIR:-$ROOT/../frontend}" +CHATWOOT_DIR="${CHATWOOT_DIR:-$ROOT/../docs/chatwoot}" LOG_DIR="${GOCHAT_SMOKE_LOG_DIR:-$ROOT/.tmp/frontend-smoke}" -REPORT_PATH="${GOCHAT_SMOKE_REPORT:-$ROOT/docs/parity/frontend-smoke-report.md}" +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:-localhost}" @@ -33,7 +33,7 @@ Modes: --no-seed Skip seed during --api-smoke and use GOCHAT_SEED_* values already present in DB. Environment: - CHATWOOT_DIR Chatwoot checkout path. Default: ../frontend (repo root frontend/) + CHATWOOT_DIR Chatwoot checkout path. Default: ../docs/chatwoot (4.15.1 upstream source) 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 @@ -312,7 +312,7 @@ run_api_smoke() { extract_json_object "$seed_raw_file" "$seed_file" else cat >"$seed_file" < session.current === true && session.id && Object.prototype.hasOwnProperty.call(session, "browser_name") && Object.prototype.hasOwnProperty.call(session, "last_activity_at"))' "profile sessions expose the current Chatwoot session" + + body="$(tmp_file)" + authed_curl -o "$body" "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id/onboarding/help_center_generation" + cp "$body" "$LOG_DIR/help_center_generation.json" + json_assert "$body" 'Object.prototype.hasOwnProperty.call(data, "generation_id") && Object.prototype.hasOwnProperty.call(data, "state") && typeof data.articles_count === "number" && typeof data.categories_count === "number"' "help center generation returns the Chatwoot onboarding contract" + + body="$(tmp_file)" + authed_curl -o "$body" "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id/calls?page=1" + cp "$body" "$LOG_DIR/calls.json" + json_assert "$body" 'data.meta && typeof data.meta.count === "number" && Number(data.meta.current_page) === 1 && typeof data.meta.total_pages === "number" && Array.isArray(data.payload)' "calls index returns Chatwoot meta and payload" + body="$(tmp_file)" authed_curl -o "$body" "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id/inboxes" cp "$body" "$LOG_DIR/inboxes.json" @@ -489,20 +504,49 @@ run_browser_smoke() { run_enterprise_api_smoke() { run_api_smoke - local seed_file account_id inbox_id conversation_id conversation_display_id conversation_uuid custom_role_id capacity_policy_id captain_assistant_id sla_policy_id + local seed_file admin_id account_id inbox_id voice_inbox_id conversation_id conversation_display_id conversation_uuid custom_role_id capacity_policy_id captain_assistant_id captain_message_id agent_bot_id sla_policy_id seed_file="$LOG_DIR/seed.json" + admin_id="$(json_value "$seed_file" 'data.admin_id')" account_id="$(json_value "$seed_file" 'data.account_id')" inbox_id="$(json_value "$seed_file" 'data.inbox_id')" + voice_inbox_id="$(json_value "$seed_file" 'data.voice_inbox_id')" conversation_id="$(json_value "$seed_file" 'data.conversation_id')" conversation_display_id="$(json_value "$seed_file" 'data.conversation_display_id || 1')" conversation_uuid="$(json_value "$seed_file" 'data.conversation_uuid || ""')" custom_role_id="$(json_value "$seed_file" 'data.custom_role_id')" capacity_policy_id="$(json_value "$seed_file" 'data.capacity_policy_id')" captain_assistant_id="$(json_value "$seed_file" 'data.captain_assistant_id')" + captain_message_id="$(json_value "$seed_file" 'data.captain_message_id')" + agent_bot_id="$(json_value "$seed_file" 'data.agent_bot_id')" sla_policy_id="$(json_value "$seed_file" 'data.sla_policy_id')" local body request_body created_id csv_file + body="$(tmp_file)" + authed_curl -o "$body" "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id/assignable_agents?inbox_ids[]=$inbox_id&include_agent_bots=true" + cp "$body" "$LOG_DIR/assignable_agents_with_bots.json" + json_assert "$body" 'Array.isArray(data.payload) && data.payload.some(owner => Number(owner.id) === Number("'"$agent_bot_id"'") && owner.assignee_type === "AgentBot" && owner.icon === "i-lucide-bot")' "assignable agents include typed AgentBot owners" + + request_body="$(tmp_file)" + node -e 'const fs = require("fs"); fs.writeFileSync(process.argv[1], JSON.stringify({ assignee_id: Number(process.argv[2]), assignee_type: "AgentBot" }));' "$request_body" "$agent_bot_id" + body="$(tmp_file)" + authed_curl -X POST --data-binary "@$request_body" -o "$body" "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id/conversations/$conversation_display_id/assign" + cp "$body" "$LOG_DIR/assign_agent_bot.json" + json_assert "$body" 'Number(data.id) === Number("'"$agent_bot_id"'") && data.name === "Smoke Agent Bot"' "conversation assignment returns the Chatwoot AgentBot slim payload" + + node -e 'const fs = require("fs"); fs.writeFileSync(process.argv[1], JSON.stringify({ assignee_id: Number(process.argv[2]), assignee_type: "User" }));' "$request_body" "$admin_id" + body="$(tmp_file)" + authed_curl -X POST --data-binary "@$request_body" -o "$body" "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id/conversations/$conversation_display_id/assign" + cp "$body" "$LOG_DIR/assign_agent_restore.json" + json_assert "$body" 'Number(data.id) === Number("'"$admin_id"'")' "conversation assignment restores a User owner" + + request_body="$(tmp_file)" + printf '{"inbound_calls_enabled":false}' >"$request_body" + authed_curl -X POST --data-binary "@$request_body" -o /dev/null "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id/inboxes/$voice_inbox_id/set_inbound_calls" + printf '{"inbound_calls_enabled":true}' >"$request_body" + authed_curl -X POST --data-binary "@$request_body" -o /dev/null "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id/inboxes/$voice_inbox_id/set_inbound_calls" + echo "ok: inbound calls toggle accepts the Chatwoot voice inbox payload" + body="$(tmp_file)" authed_curl -o "$body" "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id/applied_slas?page=1&sla_policy_id=$sla_policy_id" cp "$body" "$LOG_DIR/enterprise_applied_slas.json" @@ -608,6 +652,53 @@ run_enterprise_api_smoke() { cp "$body" "$LOG_DIR/enterprise_captain_assistants.json" json_assert "$body" 'data.payload && data.payload.some(assistant => Number(assistant.id) === Number("'$captain_assistant_id'"))' "Captain assistants list returns seeded assistant" + body="$(tmp_file)" + authed_curl -o "$body" "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id/captain/assistants/$captain_assistant_id/stats?range=7&timezone_offset=0" + cp "$body" "$LOG_DIR/enterprise_captain_stats.json" + json_assert "$body" '["conversations_handled", "auto_resolution_rate", "handoff_rate", "hours_saved", "reopen_rate", "conversation_depth", "knowledge"].every(key => Object.prototype.hasOwnProperty.call(data, key))' "Captain stats return all 4.15.1 overview metrics" + + body="$(tmp_file)" + authed_curl -o "$body" "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id/captain/assistants/$captain_assistant_id/summary?range=7&timezone_offset=0" + cp "$body" "$LOG_DIR/enterprise_captain_summary.json" + json_assert "$body" 'typeof data.message === "string" && data.message.length > 0' "Captain summary returns the overview message contract" + + body="$(tmp_file)" + authed_curl -o "$body" "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id/captain/assistants/$captain_assistant_id/drilldown?metric=conversations_handled&range=7&timezone_offset=0&page=1" + cp "$body" "$LOG_DIR/enterprise_captain_drilldown.json" + json_assert "$body" 'data.meta && Array.isArray(data.payload) && data.payload.some(item => item.conversation && Number(item.conversation.id) === Number("'"$conversation_id"'"))' "Captain drilldown returns the seeded handled conversation" + + request_body="$(tmp_file)" + node -e 'const fs = require("fs"); fs.writeFileSync(process.argv[1], JSON.stringify({ message_id: Number(process.argv[2]), report_reason: "incorrect_information", description: "4.15.1 parity smoke" }));' "$request_body" "$captain_message_id" + body="$(tmp_file)" + authed_curl -X POST --data-binary "@$request_body" -o "$body" "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id/captain/message_reports" + cp "$body" "$LOG_DIR/enterprise_captain_message_report.json" + json_assert "$body" 'data.id && Number(data.message_id) === Number("'"$captain_message_id"'") && data.report_reason === "incorrect_information"' "Captain message reports return the 4.15.1 Jbuilder shape" + + local now since until bucket_timestamp + now="$(date +%s)" + since="$((now - 86400))" + until="$((now + 86400))" + bucket_timestamp="$(date -u -d 'today 00:00:00' +%s)" + body="$(tmp_file)" + authed_curl -o "$body" "http://$API_HOST:$API_PORT/api/v2/accounts/$account_id/reports/drilldown?metric=incoming_messages_count&type=account&group_by=day&timezone_offset=0&page=1&per_page=25&since=$since&until=$until&bucket_timestamp=$bucket_timestamp" + cp "$body" "$LOG_DIR/enterprise_reports_drilldown.json" + json_assert "$body" 'data.meta && typeof data.meta.total_count === "number" && Array.isArray(data.payload) && data.payload.some(record => record.record_type === "message" && record.conversation && record.message)' "reports drilldown returns the shared 4.15.1 record envelope" + + body="$(tmp_file)" + authed_curl -o "$body" "http://$API_HOST:$API_PORT/api/v1/accounts/$account_id" + cp "$body" "$LOG_DIR/enterprise_billing_account.json" + if json_matches "$body" 'data.custom_attributes && data.custom_attributes.billing_currency === "usd"'; then + echo "ok: billing currency remains locked to the previously selected currency" + else + request_body="$(tmp_file)" + printf '{"currency":"usd"}' >"$request_body" + authed_curl -X POST --data-binary "@$request_body" -o /dev/null "http://$API_HOST:$API_PORT/enterprise/api/v1/accounts/$account_id/select_billing_currency" + fi + body="$(tmp_file)" + authed_curl -o "$body" "http://$API_HOST:$API_PORT/enterprise/api/v1/accounts/$account_id/topup_options" + cp "$body" "$LOG_DIR/enterprise_topup_options.json" + json_assert "$body" 'Number(data.id) === Number("'"$account_id"'") && data.currency === "usd" && Array.isArray(data.options)' "billing currency and topup options match the Chatwoot enterprise account contract" + request_body="$(tmp_file)" node -e 'const fs = require("fs"); fs.writeFileSync(process.argv[1], JSON.stringify({ message: "B12 enterprise copilot smoke", assistant_id: Number(process.argv[2]), conversation_id: Number(process.argv[3]) }));' "$request_body" "$captain_assistant_id" "$conversation_id" body="$(tmp_file)" diff --git a/docs/parity/chatwoot-routes-static.md b/docs/parity/chatwoot-routes-static.md index ec890945..850fabc9 100644 --- a/docs/parity/chatwoot-routes-static.md +++ b/docs/parity/chatwoot-routes-static.md @@ -1,6 +1,6 @@ # Chatwoot Route Source Declarations -Source: `reference/chatwoot/config/routes.rb` +Source: `docs/chatwoot/config/routes.rb` Ruby is unavailable in the current workspace, so this file records static route DSL declarations with source lines. It is a repeatable fallback until `bin/rails routes` can run. @@ -37,390 +37,401 @@ Ruby is unavailable in the current workspace, so this file records static route | 54 | `namespace :actions do` | | 55 | `resource :contact_merge, only: [:create]` | | 57 | `resource :bulk_actions, only: [:create]` | -| 58 | `resource :onboarding, only: [:update]` | -| 59 | `resources :agents, only: [:index, :create, :update, :destroy] do` | -| 60 | `post :bulk_create, on: :collection` | -| 62 | `namespace :captain do` | -| 63 | `resource :preferences, only: [:show, :update]` | -| 64 | `resources :assistants do` | -| 66 | `post :playground` | -| 69 | `get :tools` | -| 71 | `resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id` | -| 72 | `resources :scenarios` | -| 74 | `resources :assistant_responses` | -| 75 | `resources :bulk_actions, only: [:create]` | -| 76 | `resources :copilot_threads, only: [:index, :create] do` | -| 77 | `resources :copilot_messages, only: [:index, :create]` | -| 79 | `resources :custom_tools do` | -| 80 | `post :test, on: :collection` | -| 82 | `resources :documents, only: [:index, :show, :create, :destroy] do` | -| 83 | `post :sync, on: :member` | -| 85 | `resource :tasks, only: [], controller: 'tasks' do` | -| 86 | `post :rewrite` | -| 87 | `post :summarize` | -| 88 | `post :reply_suggestion` | -| 89 | `post :label_suggestion` | -| 90 | `post :follow_up` | -| 93 | `resource :saml_settings, only: [:show, :create, :update, :destroy]` | -| 94 | `resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do` | -| 95 | `delete :avatar, on: :member` | -| 96 | `post :reset_access_token, on: :member` | -| 97 | `post :reset_secret, on: :member` | -| 99 | `resources :contact_inboxes, only: [] do` | -| 101 | `post :filter` | -| 104 | `resources :assignable_agents, only: [:index]` | -| 105 | `resource :audit_logs, only: [:show]` | -| 106 | `resources :callbacks, only: [] do` | -| 108 | `post :register_facebook_page` | -| 109 | `get :register_facebook_page` | -| 110 | `post :facebook_pages` | -| 111 | `post :reauthorize_page` | -| 114 | `resources :canned_responses, only: [:index, :create, :update, :destroy]` | -| 115 | `resources :automation_rules, only: [:index, :create, :show, :update, :destroy] do` | -| 116 | `post :clone` | -| 118 | `resources :macros, only: [:index, :create, :show, :update, :destroy] do` | -| 119 | `post :execute, on: :member` | -| 121 | `resources :sla_policies, only: [:index, :create, :show, :update, :destroy]` | -| 122 | `resources :custom_roles, only: [:index, :create, :show, :update, :destroy]` | -| 123 | `resources :agent_capacity_policies, only: [:index, :create, :show, :update, :destroy] do` | -| 124 | `scope module: :agent_capacity_policies do` | -| 125 | `resources :users, only: [:index, :create, :destroy]` | -| 126 | `resources :inbox_limits, only: [:create, :update, :destroy]` | -| 129 | `resources :campaigns, only: [:index, :create, :show, :update, :destroy]` | -| 130 | `resources :dashboard_apps, only: [:index, :show, :create, :update, :destroy]` | -| 131 | `namespace :channels do` | -| 132 | `resource :twilio_channel, only: [:create]` | -| 134 | `resources :conversations, only: [:index, :create, :show, :update, :destroy] do` | -| 136 | `get :meta` | -| 137 | `get :search` | -| 138 | `get :unread_counts, to: 'conversations/unread_counts#index'` | -| 139 | `post :filter` | -| 141 | `scope module: :conversations do` | -| 142 | `resources :messages, only: [:index, :create, :destroy, :update] do` | -| 144 | `post :translate` | -| 145 | `post :retry` | -| 148 | `resources :assignments, only: [:create]` | -| 149 | `resources :labels, only: [:create, :index]` | -| 150 | `resource :participants, only: [:show, :create, :update, :destroy]` | -| 151 | `resource :direct_uploads, only: [:create]` | -| 152 | `resource :draft_messages, only: [:show, :update, :destroy]` | -| 155 | `post :mute` | -| 156 | `post :unmute` | -| 157 | `post :transcript` | -| 158 | `post :toggle_status` | -| 159 | `post :toggle_priority` | -| 160 | `post :toggle_typing_status` | -| 161 | `post :update_last_seen` | -| 162 | `post :unread` | -| 163 | `post :custom_attributes` | -| 164 | `get :attachments` | -| 165 | `get :inbox_assistant` | -| 166 | `get :reporting_events if ChatwootApp.enterprise?` | -| 170 | `resources :search, only: [:index] do` | -| 172 | `get :conversations` | -| 173 | `get :messages` | -| 174 | `get :contacts` | -| 175 | `get :articles` | -| 179 | `resources :companies, only: [:index, :show, :create, :update, :destroy] do` | -| 181 | `get :search` | -| 184 | `post :destroy_custom_attributes` | -| 185 | `delete :avatar` | -| 187 | `scope module: :companies do` | -| 188 | `resources :contacts, only: [:index, :create, :destroy] do` | -| 190 | `get :search` | -| 193 | `resources :conversations, only: [:index]` | -| 194 | `resources :notes, only: [:index]` | -| 197 | `resources :contacts, only: [:index, :show, :update, :create, :destroy] do` | -| 199 | `get :active` | -| 200 | `get :search` | -| 201 | `post :filter` | -| 202 | `post :import` | -| 203 | `post :export` | -| 206 | `get :contactable_inboxes` | -| 207 | `post :destroy_custom_attributes` | -| 208 | `delete :avatar` | -| 210 | `scope module: :contacts do` | -| 211 | `resources :conversations, only: [:index]` | -| 212 | `resources :contact_inboxes, only: [:create]` | -| 213 | `resources :labels, only: [:create, :index]` | -| 214 | `resources :notes` | -| 215 | `get :attachments, to: 'attachments#index'` | -| 216 | `post :call, on: :member, to: 'calls#create' if ChatwootApp.enterprise?` | -| 219 | `resources :csat_survey_responses, only: [:index] do` | -| 221 | `get :metrics` | -| 222 | `get :download` | -| 225 | `patch :update if ChatwootApp.enterprise?` | -| 228 | `resources :applied_slas, only: [:index] do` | -| 230 | `get :metrics` | -| 231 | `get :download` | -| 234 | `resources :reporting_events, only: [:index] if ChatwootApp.enterprise?` | -| 237 | `resources :whatsapp_calls, only: [:show] do` | -| 239 | `post :accept` | -| 240 | `post :reject` | -| 241 | `post :terminate` | -| 242 | `post :upload_recording` | -| 245 | `post :initiate` | -| 250 | `resources :custom_attribute_definitions, only: [:index, :show, :create, :update, :destroy]` | -| 251 | `resources :custom_filters, only: [:index, :show, :create, :update, :destroy]` | -| 252 | `resources :inboxes, only: [:index, :show, :create, :update, :destroy] do` | -| 253 | `get :assignable_agents, on: :member` | -| 254 | `get :campaigns, on: :member` | -| 255 | `get :agent_bot, on: :member` | -| 256 | `post :set_agent_bot, on: :member` | -| 257 | `delete :avatar, on: :member` | -| 258 | `post :sync_templates, on: :member` | -| 259 | `get :health, on: :member` | -| 260 | `post :register_webhook, on: :member` | -| 261 | `post :reset_secret, on: :member` | -| 263 | `resource :conference, only: %i[create destroy], controller: 'conference' do` | -| 264 | `get :token, on: :member` | -| 266 | `post :enable_whatsapp_calling, on: :member` | -| 267 | `post :disable_whatsapp_calling, on: :member` | -| 270 | `resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates' do` | -| 271 | `post :analyze, on: :collection` | -| 275 | `resources :inbox_members, only: [:create, :show], param: :inbox_id do` | -| 277 | `delete :destroy` | -| 278 | `patch :update` | -| 281 | `resources :labels, only: [:index, :show, :create, :update, :destroy]` | -| 283 | `resources :notifications, only: [:index, :update, :destroy] do` | -| 285 | `post :read_all` | -| 286 | `get :unread_count` | -| 287 | `post :destroy_all` | -| 290 | `post :snooze` | -| 291 | `post :unread` | -| 294 | `resource :notification_settings, only: [:show, :update]` | -| 296 | `resources :teams do` | -| 297 | `resources :team_members, only: [:index, :create] do` | -| 299 | `delete :destroy` | -| 300 | `patch :update` | -| 306 | `resources :assignment_policies do` | -| 307 | `resources :inboxes, only: [:index, :create, :destroy], module: :assignment_policies` | -| 310 | `resources :inboxes, only: [] do` | -| 311 | `resource :assignment_policy, only: [:show, :create, :destroy], module: :inboxes` | -| 314 | `namespace :twitter do` | -| 315 | `resource :authorization, only: [:create]` | -| 318 | `namespace :microsoft do` | -| 319 | `resource :authorization, only: [:create]` | -| 322 | `namespace :google do` | +| 58 | `resource :onboarding, only: [:update] do` | +| 59 | `get :help_center_generation` | +| 61 | `resources :agents, only: [:index, :create, :update, :destroy] do` | +| 62 | `post :bulk_create, on: :collection` | +| 64 | `namespace :captain do` | +| 65 | `resource :preferences, only: [:show, :update]` | +| 66 | `resources :assistants do` | +| 68 | `post :playground` | +| 69 | `get :stats` | +| 70 | `get :summary` | +| 71 | `get :drilldown` | +| 74 | `get :tools` | +| 76 | `resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id` | +| 77 | `resources :scenarios` | +| 79 | `resources :assistant_responses` | +| 80 | `resources :message_reports, only: [:create]` | +| 81 | `resources :bulk_actions, only: [:create]` | +| 82 | `resources :copilot_threads, only: [:index, :create] do` | +| 83 | `resources :copilot_messages, only: [:index, :create]` | +| 85 | `resources :custom_tools do` | +| 86 | `post :test, on: :collection` | +| 88 | `resources :documents, only: [:index, :show, :create, :destroy] do` | +| 89 | `post :sync, on: :member` | +| 91 | `resource :tasks, only: [], controller: 'tasks' do` | +| 92 | `post :rewrite` | +| 93 | `post :summarize` | +| 94 | `post :reply_suggestion` | +| 95 | `post :label_suggestion` | +| 96 | `post :follow_up` | +| 99 | `resource :saml_settings, only: [:show, :create, :update, :destroy]` | +| 100 | `resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do` | +| 101 | `delete :avatar, on: :member` | +| 102 | `post :reset_access_token, on: :member` | +| 103 | `post :reset_secret, on: :member` | +| 105 | `resources :contact_inboxes, only: [] do` | +| 107 | `post :filter` | +| 110 | `resources :assignable_agents, only: [:index]` | +| 111 | `resource :audit_logs, only: [:show]` | +| 112 | `resources :callbacks, only: [] do` | +| 114 | `post :register_facebook_page` | +| 115 | `get :register_facebook_page` | +| 116 | `post :facebook_pages` | +| 117 | `post :reauthorize_page` | +| 120 | `resources :canned_responses, only: [:index, :create, :update, :destroy]` | +| 121 | `resources :automation_rules, only: [:index, :create, :show, :update, :destroy] do` | +| 122 | `post :clone` | +| 124 | `resources :macros, only: [:index, :create, :show, :update, :destroy] do` | +| 125 | `post :execute, on: :member` | +| 127 | `resources :sla_policies, only: [:index, :create, :show, :update, :destroy]` | +| 128 | `resources :custom_roles, only: [:index, :create, :show, :update, :destroy]` | +| 129 | `resources :agent_capacity_policies, only: [:index, :create, :show, :update, :destroy] do` | +| 130 | `scope module: :agent_capacity_policies do` | +| 131 | `resources :users, only: [:index, :create, :destroy]` | +| 132 | `resources :inbox_limits, only: [:create, :update, :destroy]` | +| 135 | `resources :campaigns, only: [:index, :create, :show, :update, :destroy]` | +| 136 | `resources :dashboard_apps, only: [:index, :show, :create, :update, :destroy]` | +| 137 | `namespace :channels do` | +| 138 | `resource :twilio_channel, only: [:create]` | +| 140 | `resources :conversations, only: [:index, :create, :show, :update, :destroy] do` | +| 142 | `get :meta` | +| 143 | `get :search` | +| 144 | `get :unread_counts, to: 'conversations/unread_counts#index'` | +| 145 | `post :filter` | +| 147 | `scope module: :conversations do` | +| 148 | `resources :messages, only: [:index, :create, :destroy, :update] do` | +| 150 | `post :translate` | +| 151 | `post :retry` | +| 154 | `resources :assignments, only: [:create]` | +| 155 | `resources :labels, only: [:create, :index]` | +| 156 | `resource :participants, only: [:show, :create, :update, :destroy]` | +| 157 | `resource :direct_uploads, only: [:create]` | +| 158 | `resource :draft_messages, only: [:show, :update, :destroy]` | +| 161 | `post :mute` | +| 162 | `post :unmute` | +| 163 | `post :transcript` | +| 164 | `post :toggle_status` | +| 165 | `post :toggle_priority` | +| 166 | `post :toggle_typing_status` | +| 167 | `post :update_last_seen` | +| 168 | `post :unread` | +| 169 | `post :custom_attributes` | +| 170 | `get :attachments` | +| 171 | `get :inbox_assistant` | +| 172 | `get :reporting_events if ChatwootApp.enterprise?` | +| 176 | `resources :search, only: [:index] do` | +| 178 | `get :conversations` | +| 179 | `get :messages` | +| 180 | `get :contacts` | +| 181 | `get :articles` | +| 185 | `resources :companies, only: [:index, :show, :create, :update, :destroy] do` | +| 187 | `get :search` | +| 190 | `post :destroy_custom_attributes` | +| 191 | `delete :avatar` | +| 193 | `scope module: :companies do` | +| 194 | `resources :contacts, only: [:index, :create, :destroy] do` | +| 196 | `get :search` | +| 199 | `resources :conversations, only: [:index]` | +| 200 | `resources :notes, only: [:index]` | +| 203 | `resources :contacts, only: [:index, :show, :update, :create, :destroy] do` | +| 205 | `get :active` | +| 206 | `get :search` | +| 207 | `post :filter` | +| 208 | `post :import` | +| 209 | `post :export` | +| 212 | `get :contactable_inboxes` | +| 213 | `post :destroy_custom_attributes` | +| 214 | `delete :avatar` | +| 216 | `scope module: :contacts do` | +| 217 | `resources :conversations, only: [:index]` | +| 218 | `resources :contact_inboxes, only: [:create]` | +| 219 | `resources :labels, only: [:create, :index]` | +| 220 | `resources :notes` | +| 221 | `get :attachments, to: 'attachments#index'` | +| 222 | `post :call, on: :member, to: 'calls#create' if ChatwootApp.enterprise?` | +| 225 | `resources :csat_survey_responses, only: [:index] do` | +| 227 | `get :metrics` | +| 228 | `get :download` | +| 231 | `patch :update if ChatwootApp.enterprise?` | +| 234 | `resources :applied_slas, only: [:index] do` | +| 236 | `get :metrics` | +| 237 | `get :download` | +| 240 | `resources :reporting_events, only: [:index] if ChatwootApp.enterprise?` | +| 243 | `resources :calls, only: [:index]` | +| 244 | `resources :whatsapp_calls, only: [:show] do` | +| 246 | `post :accept` | +| 247 | `post :reject` | +| 248 | `post :terminate` | +| 249 | `post :upload_recording` | +| 252 | `post :initiate` | +| 257 | `resources :custom_attribute_definitions, only: [:index, :show, :create, :update, :destroy]` | +| 258 | `resources :custom_filters, only: [:index, :show, :create, :update, :destroy]` | +| 259 | `resources :inboxes, only: [:index, :show, :create, :update, :destroy] do` | +| 260 | `get :assignable_agents, on: :member` | +| 261 | `get :campaigns, on: :member` | +| 262 | `get :agent_bot, on: :member` | +| 263 | `post :set_agent_bot, on: :member` | +| 264 | `delete :avatar, on: :member` | +| 265 | `post :sync_templates, on: :member` | +| 266 | `get :health, on: :member` | +| 267 | `post :register_webhook, on: :member` | +| 268 | `post :reset_secret, on: :member` | +| 270 | `resource :conference, only: %i[create destroy], controller: 'conference' do` | +| 271 | `get :token, on: :member` | +| 273 | `post :enable_whatsapp_calling, on: :member` | +| 274 | `post :disable_whatsapp_calling, on: :member` | +| 275 | `post :set_inbound_calls, on: :member` | +| 278 | `resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates' do` | +| 279 | `post :analyze, on: :collection` | +| 283 | `resources :inbox_members, only: [:create, :show], param: :inbox_id do` | +| 285 | `delete :destroy` | +| 286 | `patch :update` | +| 289 | `resources :labels, only: [:index, :show, :create, :update, :destroy]` | +| 291 | `resources :notifications, only: [:index, :update, :destroy] do` | +| 293 | `post :read_all` | +| 294 | `get :unread_count` | +| 295 | `post :destroy_all` | +| 298 | `post :snooze` | +| 299 | `post :unread` | +| 302 | `resource :notification_settings, only: [:show, :update]` | +| 304 | `resources :teams do` | +| 305 | `resources :team_members, only: [:index, :create] do` | +| 307 | `delete :destroy` | +| 308 | `patch :update` | +| 314 | `resources :assignment_policies do` | +| 315 | `resources :inboxes, only: [:index, :create, :destroy], module: :assignment_policies` | +| 318 | `resources :inboxes, only: [] do` | +| 319 | `resource :assignment_policy, only: [:show, :create, :destroy], module: :inboxes` | +| 322 | `namespace :twitter do` | | 323 | `resource :authorization, only: [:create]` | -| 326 | `namespace :instagram do` | +| 326 | `namespace :microsoft do` | | 327 | `resource :authorization, only: [:create]` | -| 330 | `namespace :tiktok do` | +| 330 | `namespace :google do` | | 331 | `resource :authorization, only: [:create]` | -| 334 | `namespace :notion do` | +| 334 | `namespace :instagram do` | | 335 | `resource :authorization, only: [:create]` | -| 338 | `namespace :whatsapp do` | +| 338 | `namespace :tiktok do` | | 339 | `resource :authorization, only: [:create]` | -| 342 | `resources :webhooks, only: [:index, :create, :update, :destroy]` | -| 343 | `namespace :integrations do` | -| 344 | `resources :apps, only: [:index, :show]` | -| 345 | `resources :hooks, only: [:show, :create, :update, :destroy] do` | -| 347 | `post :process_event` | -| 350 | `resource :slack, only: [:create, :update, :destroy], controller: 'slack' do` | -| 352 | `get :list_all_channels` | -| 355 | `resource :dyte, controller: 'dyte', only: [] do` | -| 357 | `post :create_a_meeting` | -| 358 | `post :add_participant_to_meeting` | -| 361 | `resource :shopify, controller: 'shopify', only: [:destroy] do` | -| 363 | `post :auth` | -| 364 | `get :orders` | -| 367 | `resource :linear, controller: 'linear', only: [] do` | -| 369 | `delete :destroy` | -| 370 | `get :teams` | -| 371 | `get :team_entities` | -| 372 | `post :create_issue` | -| 373 | `post :link_issue` | -| 374 | `post :unlink_issue` | -| 375 | `get :search_issue` | -| 376 | `get :linked_issues` | -| 379 | `resource :notion, controller: 'notion', only: [] do` | -| 381 | `delete :destroy` | -| 385 | `resources :portals do` | -| 387 | `patch :archive` | -| 388 | `delete :logo` | -| 389 | `post :send_instructions` | -| 390 | `get :ssl_status` | -| 392 | `resources :categories do` | -| 393 | `post :reorder, on: :collection` | -| 395 | `namespace :articles do` | -| 396 | `resource :bulk_actions, only: [] do` | -| 397 | `post :translate` | -| 398 | `patch :update_status` | -| 399 | `patch :update_category` | -| 400 | `delete :delete_articles` | -| 403 | `resources :articles do` | -| 404 | `post :reorder, on: :collection` | -| 408 | `resources :upload, only: [:create]` | -| 414 | `namespace :integrations do` | -| 415 | `resources :webhooks, only: [:create]` | -| 419 | `post 'auth/saml_login', to: 'auth#saml_login'` | -| 421 | `resource :profile, only: [:show, :update] do` | -| 422 | `delete :avatar, on: :collection` | -| 424 | `post :availability` | -| 425 | `post :auto_offline` | -| 426 | `put :set_active_account` | -| 427 | `post :resend_confirmation` | -| 428 | `post :reset_access_token` | -| 432 | `scope module: 'profile' do` | -| 433 | `resource :mfa, controller: 'mfa', only: [:show, :create, :destroy] do` | -| 434 | `post :verify` | -| 435 | `post :backup_codes` | -| 440 | `resource :notification_subscriptions, only: [:create, :destroy]` | -| 442 | `namespace :widget do` | -| 443 | `resource :direct_uploads, only: [:create]` | -| 444 | `resource :config, only: [:create]` | -| 445 | `resources :campaigns, only: [:index]` | -| 446 | `resources :events, only: [:create]` | -| 447 | `resources :messages, only: [:index, :create, :update]` | -| 448 | `resources :conversations, only: [:index, :create] do` | -| 450 | `post :destroy_custom_attributes` | -| 451 | `post :set_custom_attributes` | -| 452 | `post :update_last_seen` | -| 453 | `post :toggle_typing` | -| 454 | `post :transcript` | -| 455 | `get :toggle_status` | -| 458 | `resource :contact, only: [:show, :update] do` | -| 460 | `post :destroy_custom_attributes` | -| 461 | `patch :set_user` | -| 464 | `resources :inbox_members, only: [:index]` | -| 465 | `resources :labels, only: [:create, :destroy]` | -| 466 | `namespace :integrations do` | -| 467 | `resource :dyte, controller: 'dyte', only: [] do` | -| 469 | `post :add_participant_to_meeting` | -| 476 | `namespace :v2 do` | -| 477 | `resources :accounts, only: [:create] do` | -| 478 | `scope module: :accounts do` | -| 479 | `resources :summary_reports, only: [] do` | -| 481 | `get :agent` | -| 482 | `get :team` | -| 483 | `get :inbox` | -| 484 | `get :label` | -| 485 | `get :channel` | -| 488 | `resources :reports, only: [:index] do` | -| 490 | `get :summary` | -| 491 | `get :bot_summary` | -| 492 | `get :agents` | -| 493 | `get :inboxes` | -| 494 | `get :labels` | -| 495 | `get :teams` | -| 496 | `get :conversations` | -| 497 | `get :conversations_summary` | -| 498 | `get :conversation_traffic` | -| 499 | `get :bot_metrics` | -| 500 | `get :inbox_label_matrix` | -| 501 | `get :first_response_time_distribution` | -| 502 | `get :outgoing_messages_count` | -| 505 | `resource :year_in_review, only: [:show]` | -| 506 | `resources :live_reports, only: [] do` | -| 508 | `get :conversation_metrics` | -| 509 | `get :grouped_conversation_metrics` | -| 518 | `namespace :enterprise, defaults: { format: 'json' } do` | -| 519 | `namespace :api do` | -| 520 | `namespace :v1 do` | -| 521 | `resources :accounts do` | -| 523 | `post :checkout` | -| 524 | `post :subscription` | -| 525 | `get :limits` | -| 526 | `post :toggle_deletion` | -| 527 | `post :topup_checkout` | -| 533 | `post 'webhooks/stripe', to: 'webhooks/stripe#process_payload'` | -| 534 | `post 'webhooks/firecrawl', to: 'webhooks/firecrawl#process_payload'` | -| 540 | `namespace :platform, defaults: { format: 'json' } do` | -| 541 | `namespace :api do` | -| 542 | `namespace :v1 do` | -| 543 | `resources :users, only: [:create, :show, :update, :destroy] do` | -| 545 | `get :login` | -| 546 | `post :token` | -| 549 | `resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do` | -| 550 | `delete :avatar, on: :member` | -| 552 | `resources :accounts, only: [:index, :create, :show, :update, :destroy] do` | -| 553 | `resources :account_users, only: [:index, :create] do` | -| 555 | `delete :destroy` | -| 558 | `resources :email_channel_migrations, only: [:create]` | -| 566 | `namespace :public, defaults: { format: 'json' } do` | -| 567 | `namespace :api do` | -| 568 | `namespace :v1 do` | -| 569 | `resources :inboxes do` | -| 570 | `scope module: :inboxes do` | -| 571 | `resources :contacts, only: [:create, :show, :update] do` | -| 572 | `resources :conversations, only: [:index, :create, :show] do` | -| 574 | `post :toggle_status` | -| 575 | `post :toggle_typing` | -| 576 | `post :update_last_seen` | -| 579 | `resources :messages, only: [:index, :create, :update]` | -| 585 | `resources :csat_survey, only: [:show, :update]` | -| 590 | `get 'hc/:slug', to: 'public/api/v1/portals#show'` | -| 591 | `get 'hc/:slug/sitemap.xml', to: 'public/api/v1/portals#sitemap'` | -| 592 | `get 'hc/:slug/:locale', to: 'public/api/v1/portals#show', as: :public_portal_locale` | -| 593 | `get 'hc/:slug/:locale/search', to: 'public/api/v1/portals/search#index', as: :portal_search` | -| 594 | `get 'hc/:slug/:locale/articles', to: 'public/api/v1/portals/articles#index'` | -| 595 | `get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index'` | -| 596 | `get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show', as: :public_portal_category` | -| 597 | `get 'hc/:slug/:locale/categories/:category_slug/articles', to: 'public/api/v1/portals/articles#index'` | -| 598 | `get 'hc/:slug/articles/:article_slug.png', to: 'public/api/v1/portals/articles#tracking_pixel'` | -| 599 | `get 'hc/:slug/articles/:article_slug.md', to: 'public/api/v1/portals/articles#show_markdown', as: :public_portal_article_markdown,` | -| 601 | `get 'hc/:slug/articles/:article_slug', to: 'public/api/v1/portals/articles#show', as: :public_portal_article` | -| 605 | `resource :app, only: [:index] do` | -| 606 | `resources :accounts do` | -| 607 | `resources :conversations, only: [:show]` | -| 613 | `mount Facebook::Messenger::Server, at: 'bot'` | -| 614 | `get 'webhooks/twitter', to: 'api/v1/webhooks#twitter_crc'` | -| 615 | `post 'webhooks/twitter', to: 'api/v1/webhooks#twitter_events'` | -| 616 | `post 'webhooks/line/:line_channel_id', to: 'webhooks/line#process_payload'` | -| 617 | `post 'webhooks/telegram/:bot_token', to: 'webhooks/telegram#process_payload'` | -| 618 | `post 'webhooks/sms/:phone_number', to: 'webhooks/sms#process_payload'` | -| 619 | `get 'webhooks/whatsapp/:phone_number', to: 'webhooks/whatsapp#verify'` | -| 620 | `post 'webhooks/whatsapp/:phone_number', to: 'webhooks/whatsapp#process_payload'` | -| 621 | `get 'webhooks/instagram', to: 'webhooks/instagram#verify'` | -| 622 | `post 'webhooks/instagram', to: 'webhooks/instagram#events'` | -| 623 | `post 'webhooks/tiktok', to: 'webhooks/tiktok#events'` | -| 624 | `post 'webhooks/shopify', to: 'webhooks/shopify#events'` | -| 626 | `namespace :twitter do` | -| 627 | `resource :callback, only: [:show]` | -| 630 | `namespace :linear do` | -| 631 | `resource :callback, only: [:show]` | -| 634 | `namespace :shopify do` | -| 635 | `resource :callback, only: [:show]` | -| 638 | `namespace :twilio do` | -| 639 | `resources :callback, only: [:create]` | -| 640 | `resources :delivery_status, only: [:create]` | -| 643 | `post 'voice/call/:phone', to: 'voice#call_twiml', as: :voice_call` | -| 644 | `post 'voice/status/:phone', to: 'voice#status', as: :voice_status` | -| 645 | `post 'voice/conference_status/:phone', to: 'voice#conference_status', as: :voice_conference_status` | -| 646 | `post 'voice/recording_status/:phone', to: 'voice#recording_status', as: :voice_recording_status` | -| 650 | `get 'microsoft/callback', to: 'microsoft/callbacks#show'` | -| 651 | `get 'google/callback', to: 'google/callbacks#show'` | -| 652 | `get 'instagram/callback', to: 'instagram/callbacks#show'` | -| 653 | `get 'tiktok/callback', to: 'tiktok/callbacks#show'` | -| 654 | `get 'notion/callback', to: 'notion/callbacks#show'` | -| 657 | `get '.well-known/assetlinks.json' => 'android_app#assetlinks'` | -| 658 | `get '.well-known/apple-app-site-association' => 'apple_app#site_association'` | -| 659 | `get '.well-known/microsoft-identity-association.json' => 'microsoft#identity_association'` | -| 660 | `get '.well-known/cf-custom-hostname-challenge/:id', to: 'custom_domains#verify'` | -| 669 | `get 'super_admin/logout', to: 'super_admin/devise/sessions#destroy'` | -| 670 | `namespace :super_admin do` | -| 673 | `resource :app_config, only: [:show, :create]` | -| 674 | `resource :push_diagnostics, only: [:show, :create] do` | -| 675 | `post :destroy_subscriptions, on: :collection` | -| 679 | `resources :accounts, only: [:index, :new, :create, :show, :edit, :update, :destroy] do` | -| 680 | `post :seed, on: :member` | -| 681 | `post :reset_cache, on: :member` | -| 683 | `resources :users, only: [:index, :new, :create, :show, :edit, :update, :destroy] do` | -| 684 | `delete :avatar, on: :member, action: :destroy_avatar` | -| 687 | `resources :access_tokens, only: [:index, :show]` | -| 688 | `resources :installation_configs, only: [:index, :new, :create, :show, :edit, :update]` | -| 689 | `resources :agent_bots, only: [:index, :new, :create, :show, :edit, :update, :destroy] do` | -| 690 | `delete :avatar, on: :member, action: :destroy_avatar` | -| 692 | `resources :platform_apps, only: [:index, :new, :create, :show, :edit, :update, :destroy]` | -| 693 | `resources :platform_banners` | -| 694 | `resource :instance_status, only: [:show]` | -| 696 | `resource :settings, only: [:show] do` | -| 697 | `get :refresh, on: :collection` | -| 701 | `resources :account_users, only: [:new, :create, :show, :destroy]` | -| 704 | `mount Sidekiq::Web => '/monitoring/sidekiq'` | -| 708 | `namespace :installation do` | -| 709 | `get 'onboarding', to: 'onboarding#index'` | -| 710 | `post 'onboarding', to: 'onboarding#create'` | -| 715 | `get '/swagger/*path', to: 'swagger#respond'` | -| 716 | `get '/swagger', to: 'swagger#respond'` | -| 720 | `resources :widget_tests, only: [:index] unless Rails.env.production?` | +| 342 | `namespace :notion do` | +| 343 | `resource :authorization, only: [:create]` | +| 346 | `namespace :whatsapp do` | +| 347 | `resource :authorization, only: [:create]` | +| 350 | `resources :webhooks, only: [:index, :create, :update, :destroy]` | +| 351 | `namespace :integrations do` | +| 352 | `resources :apps, only: [:index, :show]` | +| 353 | `resources :hooks, only: [:show, :create, :update, :destroy] do` | +| 355 | `post :process_event` | +| 358 | `resource :slack, only: [:create, :update, :destroy], controller: 'slack' do` | +| 360 | `get :list_all_channels` | +| 363 | `resource :dyte, controller: 'dyte', only: [] do` | +| 365 | `post :create_a_meeting` | +| 366 | `post :add_participant_to_meeting` | +| 369 | `resource :shopify, controller: 'shopify', only: [:destroy] do` | +| 371 | `post :auth` | +| 372 | `get :orders` | +| 375 | `resource :linear, controller: 'linear', only: [] do` | +| 377 | `delete :destroy` | +| 378 | `get :teams` | +| 379 | `get :team_entities` | +| 380 | `post :create_issue` | +| 381 | `post :link_issue` | +| 382 | `post :unlink_issue` | +| 383 | `get :search_issue` | +| 384 | `get :linked_issues` | +| 387 | `resource :notion, controller: 'notion', only: [] do` | +| 389 | `delete :destroy` | +| 393 | `resources :portals do` | +| 395 | `patch :archive` | +| 396 | `delete :logo` | +| 397 | `post :send_instructions` | +| 398 | `get :ssl_status` | +| 400 | `resources :categories do` | +| 401 | `post :reorder, on: :collection` | +| 403 | `namespace :articles do` | +| 404 | `resource :bulk_actions, only: [] do` | +| 405 | `post :translate` | +| 406 | `patch :update_status` | +| 407 | `patch :update_category` | +| 408 | `delete :delete_articles` | +| 411 | `resources :articles do` | +| 412 | `post :reorder, on: :collection` | +| 416 | `resources :upload, only: [:create]` | +| 422 | `namespace :integrations do` | +| 423 | `resources :webhooks, only: [:create]` | +| 427 | `post 'auth/saml_login', to: 'auth#saml_login'` | +| 429 | `resource :profile, only: [:show, :update] do` | +| 430 | `delete :avatar, on: :collection` | +| 432 | `post :availability` | +| 433 | `post :auto_offline` | +| 434 | `put :set_active_account` | +| 435 | `post :resend_confirmation` | +| 436 | `post :reset_access_token` | +| 440 | `scope module: 'profile' do` | +| 441 | `resource :mfa, controller: 'mfa', only: [:show, :create, :destroy] do` | +| 442 | `post :verify` | +| 443 | `post :backup_codes` | +| 445 | `resources :sessions, only: [:index, :destroy]` | +| 449 | `resource :notification_subscriptions, only: [:create, :destroy]` | +| 451 | `namespace :widget do` | +| 452 | `resource :direct_uploads, only: [:create]` | +| 453 | `resource :config, only: [:create]` | +| 454 | `resources :campaigns, only: [:index]` | +| 455 | `resources :events, only: [:create]` | +| 456 | `resources :messages, only: [:index, :create, :update]` | +| 457 | `resources :conversations, only: [:index, :create] do` | +| 459 | `post :destroy_custom_attributes` | +| 460 | `post :set_custom_attributes` | +| 461 | `post :update_last_seen` | +| 462 | `post :toggle_typing` | +| 463 | `post :transcript` | +| 464 | `get :toggle_status` | +| 467 | `resource :contact, only: [:show, :update] do` | +| 469 | `post :destroy_custom_attributes` | +| 470 | `patch :set_user` | +| 473 | `resources :inbox_members, only: [:index]` | +| 474 | `resources :labels, only: [:create, :destroy]` | +| 475 | `namespace :integrations do` | +| 476 | `resource :dyte, controller: 'dyte', only: [] do` | +| 478 | `post :add_participant_to_meeting` | +| 485 | `namespace :v2 do` | +| 486 | `resources :accounts, only: [:create] do` | +| 487 | `scope module: :accounts do` | +| 488 | `resources :summary_reports, only: [] do` | +| 490 | `get :agent` | +| 491 | `get :team` | +| 492 | `get :inbox` | +| 493 | `get :label` | +| 494 | `get :channel` | +| 497 | `resources :reports, only: [:index] do` | +| 499 | `get :summary` | +| 500 | `get :bot_summary` | +| 501 | `get :agents` | +| 502 | `get :inboxes` | +| 503 | `get :labels` | +| 504 | `get :teams` | +| 505 | `get :conversations` | +| 506 | `get :conversations_summary` | +| 507 | `get :conversation_traffic` | +| 508 | `get :drilldown` | +| 509 | `get :bot_metrics` | +| 510 | `get :inbox_label_matrix` | +| 511 | `get :first_response_time_distribution` | +| 512 | `get :outgoing_messages_count` | +| 515 | `resource :year_in_review, only: [:show]` | +| 516 | `resources :live_reports, only: [] do` | +| 518 | `get :conversation_metrics` | +| 519 | `get :grouped_conversation_metrics` | +| 528 | `namespace :enterprise, defaults: { format: 'json' } do` | +| 529 | `namespace :api do` | +| 530 | `namespace :v1 do` | +| 531 | `resources :accounts do` | +| 533 | `post :checkout` | +| 534 | `post :subscription` | +| 535 | `post :select_billing_currency` | +| 536 | `get :limits` | +| 537 | `post :toggle_deletion` | +| 538 | `post :topup_checkout` | +| 539 | `get :topup_options` | +| 545 | `post 'webhooks/stripe', to: 'webhooks/stripe#process_payload'` | +| 546 | `post 'webhooks/firecrawl', to: 'webhooks/firecrawl#process_payload'` | +| 552 | `namespace :platform, defaults: { format: 'json' } do` | +| 553 | `namespace :api do` | +| 554 | `namespace :v1 do` | +| 555 | `resources :users, only: [:create, :show, :update, :destroy] do` | +| 557 | `get :login` | +| 558 | `post :token` | +| 561 | `resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do` | +| 562 | `delete :avatar, on: :member` | +| 564 | `resources :accounts, only: [:index, :create, :show, :update, :destroy] do` | +| 565 | `resources :account_users, only: [:index, :create] do` | +| 567 | `delete :destroy` | +| 570 | `resources :email_channel_migrations, only: [:create]` | +| 578 | `namespace :public, defaults: { format: 'json' } do` | +| 579 | `namespace :api do` | +| 580 | `namespace :v1 do` | +| 581 | `resources :inboxes do` | +| 582 | `scope module: :inboxes do` | +| 583 | `resources :contacts, only: [:create, :show, :update] do` | +| 584 | `resources :conversations, only: [:index, :create, :show] do` | +| 586 | `post :toggle_status` | +| 587 | `post :toggle_typing` | +| 588 | `post :update_last_seen` | +| 591 | `resources :messages, only: [:index, :create, :update]` | +| 597 | `resources :csat_survey, only: [:show, :update]` | +| 602 | `get 'hc/:slug', to: 'public/api/v1/portals#show'` | +| 603 | `get 'hc/:slug/sitemap.xml', to: 'public/api/v1/portals#sitemap'` | +| 604 | `get 'hc/:slug/:locale', to: 'public/api/v1/portals#show', as: :public_portal_locale` | +| 605 | `get 'hc/:slug/:locale/search', to: 'public/api/v1/portals/search#index', as: :portal_search` | +| 606 | `get 'hc/:slug/:locale/articles', to: 'public/api/v1/portals/articles#index'` | +| 607 | `get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index'` | +| 608 | `get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show', as: :public_portal_category` | +| 609 | `get 'hc/:slug/:locale/categories/:category_slug/articles', to: 'public/api/v1/portals/articles#index'` | +| 610 | `get 'hc/:slug/articles/:article_slug.png', to: 'public/api/v1/portals/articles#tracking_pixel'` | +| 611 | `get 'hc/:slug/articles/:article_slug.md', to: 'public/api/v1/portals/articles#show_markdown', as: :public_portal_article_markdown,` | +| 613 | `get 'hc/:slug/articles/:article_slug', to: 'public/api/v1/portals/articles#show', as: :public_portal_article` | +| 617 | `resource :app, only: [:index] do` | +| 618 | `resources :accounts do` | +| 619 | `resources :conversations, only: [:show]` | +| 625 | `mount Facebook::Messenger::Server, at: 'bot'` | +| 626 | `get 'webhooks/twitter', to: 'api/v1/webhooks#twitter_crc'` | +| 627 | `post 'webhooks/twitter', to: 'api/v1/webhooks#twitter_events'` | +| 628 | `post 'webhooks/line/:line_channel_id', to: 'webhooks/line#process_payload'` | +| 629 | `post 'webhooks/telegram/:bot_token', to: 'webhooks/telegram#process_payload'` | +| 630 | `post 'webhooks/sms/:phone_number', to: 'webhooks/sms#process_payload'` | +| 631 | `get 'webhooks/whatsapp/:phone_number', to: 'webhooks/whatsapp#verify'` | +| 632 | `post 'webhooks/whatsapp/:phone_number', to: 'webhooks/whatsapp#process_payload'` | +| 633 | `get 'webhooks/instagram', to: 'webhooks/instagram#verify'` | +| 634 | `post 'webhooks/instagram', to: 'webhooks/instagram#events'` | +| 635 | `post 'webhooks/tiktok', to: 'webhooks/tiktok#events'` | +| 636 | `post 'webhooks/shopify', to: 'webhooks/shopify#events'` | +| 638 | `namespace :twitter do` | +| 639 | `resource :callback, only: [:show]` | +| 642 | `namespace :linear do` | +| 643 | `resource :callback, only: [:show]` | +| 646 | `namespace :shopify do` | +| 647 | `resource :callback, only: [:show]` | +| 650 | `namespace :twilio do` | +| 651 | `resources :callback, only: [:create]` | +| 652 | `resources :delivery_status, only: [:create]` | +| 655 | `post 'voice/call/:phone', to: 'voice#call_twiml', as: :voice_call` | +| 656 | `post 'voice/status/:phone', to: 'voice#status', as: :voice_status` | +| 657 | `post 'voice/conference_status/:phone', to: 'voice#conference_status', as: :voice_conference_status` | +| 658 | `post 'voice/recording_status/:phone', to: 'voice#recording_status', as: :voice_recording_status` | +| 662 | `get 'microsoft/callback', to: 'microsoft/callbacks#show'` | +| 663 | `get 'google/callback', to: 'google/callbacks#show'` | +| 664 | `get 'instagram/callback', to: 'instagram/callbacks#show'` | +| 665 | `get 'tiktok/callback', to: 'tiktok/callbacks#show'` | +| 666 | `get 'notion/callback', to: 'notion/callbacks#show'` | +| 669 | `get '.well-known/assetlinks.json' => 'android_app#assetlinks'` | +| 670 | `get '.well-known/apple-app-site-association' => 'apple_app#site_association'` | +| 671 | `get '.well-known/microsoft-identity-association.json' => 'microsoft#identity_association'` | +| 672 | `get '.well-known/cf-custom-hostname-challenge/:id', to: 'custom_domains#verify'` | +| 681 | `get 'super_admin/logout', to: 'super_admin/devise/sessions#destroy'` | +| 682 | `namespace :super_admin do` | +| 685 | `resource :app_config, only: [:show, :create]` | +| 686 | `resource :push_diagnostics, only: [:show, :create] do` | +| 687 | `post :destroy_subscriptions, on: :collection` | +| 691 | `resources :accounts, only: [:index, :new, :create, :show, :edit, :update, :destroy] do` | +| 692 | `post :seed, on: :member` | +| 693 | `post :reset_cache, on: :member` | +| 695 | `resources :users, only: [:index, :new, :create, :show, :edit, :update, :destroy] do` | +| 696 | `delete :avatar, on: :member, action: :destroy_avatar` | +| 699 | `resources :access_tokens, only: [:index, :show]` | +| 700 | `resources :installation_configs, only: [:index, :new, :create, :show, :edit, :update]` | +| 701 | `resources :agent_bots, only: [:index, :new, :create, :show, :edit, :update, :destroy] do` | +| 702 | `delete :avatar, on: :member, action: :destroy_avatar` | +| 704 | `resources :platform_apps, only: [:index, :new, :create, :show, :edit, :update, :destroy]` | +| 705 | `resources :platform_banners` | +| 706 | `resource :instance_status, only: [:show]` | +| 708 | `resource :settings, only: [:show] do` | +| 709 | `get :refresh, on: :collection` | +| 713 | `resources :account_users, only: [:new, :create, :show, :destroy]` | +| 716 | `mount Sidekiq::Web => '/monitoring/sidekiq'` | +| 720 | `namespace :installation do` | +| 721 | `get 'onboarding', to: 'onboarding#index'` | +| 722 | `post 'onboarding', to: 'onboarding#create'` | +| 727 | `get '/swagger/*path', to: 'swagger#respond'` | +| 728 | `get '/swagger', to: 'swagger#respond'` | +| 732 | `resources :widget_tests, only: [:index] unless Rails.env.production?` | diff --git a/docs/parity/frontend-smoke-report.md b/docs/parity/frontend-smoke-report.md index 3d347366..6ea8e1ea 100644 --- a/docs/parity/frontend-smoke-report.md +++ b/docs/parity/frontend-smoke-report.md @@ -1,6 +1,6 @@ # Frontend Smoke Report -Updated: 2026-06-13T12:24:46Z +Updated: 2026-07-13T17:38:17Z ## Status @@ -46,8 +46,8 @@ scripts/parity_frontend_smoke.sh --enterprise-browser-smoke ## Reused Chatwoot Frontend - 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:13000 pnpm exec vite --host localhost --port 3036)` +- Source: `/home/rogee/Projects/gochat/docs/chatwoot` +- Command: `(cd /home/rogee/Projects/gochat/docs/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 diff --git a/docs/parity/gochat-routes.txt b/docs/parity/gochat-routes.txt index 97f656d2..81b5074e 100644 --- a/docs/parity/gochat-routes.txt +++ b/docs/parity/gochat-routes.txt @@ -21,6 +21,7 @@ DELETE /api/v1/accounts/:account_id/captain/assistants/:assistant_id DELETE /api/v1/accounts/:account_id/captain/assistants/:assistant_id/documents/:document_id DELETE /api/v1/accounts/:account_id/captain/assistants/:assistant_id/inboxes/:inbox_id DELETE /api/v1/accounts/:account_id/captain/assistants/:assistant_id/scenarios/:scenario_id +DELETE /api/v1/accounts/:account_id/captain/auto_reply_rules/:rule_id DELETE /api/v1/accounts/:account_id/captain/copilot_threads/:thread_id DELETE /api/v1/accounts/:account_id/captain/custom_tools/:tool_id DELETE /api/v1/accounts/:account_id/captain/documents/:document_id @@ -114,6 +115,7 @@ DELETE /api/v1/notifications/destroy_all DELETE /api/v1/profile/avatar DELETE /api/v1/profile/mfa DELETE /api/v1/profile/mfa/ +DELETE /api/v1/profile/sessions/:id DELETE /api/v1/push_subscriptions/:id DELETE /api/v1/widget/labels/:label_id DELETE /auth/sign_out @@ -168,6 +170,7 @@ GET /api/v1/accounts/:account_id/automation_rules/:automation_id GET /api/v1/accounts/:account_id/banners GET /api/v1/accounts/:account_id/cache_keys GET /api/v1/accounts/:account_id/callbacks/register_facebook_page +GET /api/v1/accounts/:account_id/calls GET /api/v1/accounts/:account_id/campaigns GET /api/v1/accounts/:account_id/campaigns/ GET /api/v1/accounts/:account_id/campaigns/:campaign_id @@ -182,10 +185,16 @@ 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/ GET /api/v1/accounts/:account_id/captain/assistants/:assistant_id/documents/:document_id +GET /api/v1/accounts/:account_id/captain/assistants/:assistant_id/drilldown GET /api/v1/accounts/:account_id/captain/assistants/:assistant_id/inboxes GET /api/v1/accounts/:account_id/captain/assistants/:assistant_id/scenarios/ GET /api/v1/accounts/:account_id/captain/assistants/:assistant_id/scenarios/:scenario_id +GET /api/v1/accounts/:account_id/captain/assistants/:assistant_id/stats +GET /api/v1/accounts/:account_id/captain/assistants/:assistant_id/summary GET /api/v1/accounts/:account_id/captain/assistants/tools +GET /api/v1/accounts/:account_id/captain/auto_reply_rules +GET /api/v1/accounts/:account_id/captain/auto_reply_rules/ +GET /api/v1/accounts/:account_id/captain/auto_reply_rules/:rule_id 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 @@ -250,6 +259,8 @@ 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/copilot/config +GET /api/v1/accounts/:account_id/copilot/config/ 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 @@ -332,9 +343,11 @@ GET /api/v1/accounts/:account_id/microsoft/webhooks GET /api/v1/accounts/:account_id/microsoft_channels/authorization GET /api/v1/accounts/:account_id/notification_settings GET /api/v1/accounts/:account_id/notification_settings/ +GET /api/v1/accounts/:account_id/notifications GET /api/v1/accounts/:account_id/notifications/ GET /api/v1/accounts/:account_id/notifications/:notification_id GET /api/v1/accounts/:account_id/notifications/unread_count +GET /api/v1/accounts/:account_id/onboarding/help_center_generation GET /api/v1/accounts/:account_id/platform_apps/ GET /api/v1/accounts/:account_id/platform_apps/:platform_app_id GET /api/v1/accounts/:account_id/platform_apps/:platform_app_id/access_tokens @@ -348,6 +361,7 @@ GET /api/v1/accounts/:account_id/portals/:portal_id/articles/ GET /api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id GET /api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id/edit GET /api/v1/accounts/:account_id/portals/:portal_id/articles/search +GET /api/v1/accounts/:account_id/portals/:portal_id/articles/semantic_search GET /api/v1/accounts/:account_id/portals/:portal_id/articles/status_counts GET /api/v1/accounts/:account_id/portals/:portal_id/categories GET /api/v1/accounts/:account_id/portals/:portal_id/categories/ @@ -421,6 +435,7 @@ GET /api/v1/oidc/discovery GET /api/v1/profile GET /api/v1/profile/mfa GET /api/v1/profile/mfa/ +GET /api/v1/profile/sessions GET /api/v1/push_subscriptions GET /api/v1/saml/login GET /api/v1/saml/metadata @@ -443,6 +458,7 @@ GET /api/v2/accounts/:account_id/reports/bot_summary GET /api/v2/accounts/:account_id/reports/conversation_traffic GET /api/v2/accounts/:account_id/reports/conversations GET /api/v2/accounts/:account_id/reports/conversations_summary +GET /api/v2/accounts/:account_id/reports/drilldown GET /api/v2/accounts/:account_id/reports/first_response_time_distribution GET /api/v2/accounts/:account_id/reports/inbox_label_matrix GET /api/v2/accounts/:account_id/reports/inboxes @@ -461,7 +477,9 @@ GET /app/*params GET /auth/validate_token GET /cable GET /enterprise/api/v1/accounts/:account_id/limits +GET /enterprise/api/v1/accounts/:account_id/topup_options GET /enterprise/api/v1/limits +GET /enterprise/api/v1/topup_options GET /google/callback GET /hc/:slug GET /hc/:slug/:locale @@ -492,6 +510,8 @@ GET /platform/api/v1/apps/:id/permissibles GET /platform/api/v1/apps/search GET /platform/api/v1/banners GET /platform/api/v1/banners/:id +GET /platform/api/v1/copilot/config +GET /platform/api/v1/copilot/embeddings/reindex GET /platform/api/v1/installation_configs GET /platform/api/v1/installation_configs/:id GET /platform/api/v1/users @@ -509,6 +529,7 @@ GET /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:convers GET /shopify/callback GET /tiktok/callback GET /twitter/callback +GET /webhooks/fake/:identifier GET /webhooks/instagram GET /webhooks/tiktok/:business_id GET /webhooks/twitter @@ -521,14 +542,18 @@ GET /widget/widget/:website_token/pre_chat_form GET /widget/widget/:website_token/theme_config GET /widget/widget/:website_token/uploads/:upload_uuid GET /ws +PATCH /api/v1/accounts/:account_id PATCH /api/v1/accounts/:account_id/agent_bot_inboxes/:agent_bot_inbox_id/status PATCH /api/v1/accounts/:account_id/agent_bots/:agent_bot_id PATCH /api/v1/accounts/:account_id/agent_capacity_policies/:id PATCH /api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits/:limit_id PATCH /api/v1/accounts/:account_id/agents/:agent_id PATCH /api/v1/accounts/:account_id/assignment_policies/:policy_id +PATCH /api/v1/accounts/:account_id/automation_rules/:automation_id PATCH /api/v1/accounts/:account_id/campaigns/:campaign_id PATCH /api/v1/accounts/:account_id/canned_responses/:id +PATCH /api/v1/accounts/:account_id/captain/assistant_responses/:response_id +PATCH /api/v1/accounts/:account_id/captain/assistants/:assistant_id PATCH /api/v1/accounts/:account_id/channels/facebook_channel/:fb_id PATCH /api/v1/accounts/:account_id/companies/:company_id PATCH /api/v1/accounts/:account_id/contacts/:contact_id @@ -560,6 +585,8 @@ PATCH /api/v1/accounts/:account_id/inboxes/:inbox_id/twilio_sms_channels/:tw_id PATCH /api/v1/accounts/:account_id/integrations/hooks/:id PATCH /api/v1/accounts/:account_id/integrations/slack PATCH /api/v1/accounts/:account_id/integrations/slack/ +PATCH /api/v1/accounts/:account_id/labels/:tag_id +PATCH /api/v1/accounts/:account_id/macros/:macro_id PATCH /api/v1/accounts/:account_id/notification_settings PATCH /api/v1/accounts/:account_id/notification_settings/ PATCH /api/v1/accounts/:account_id/notifications/:notification_id @@ -601,6 +628,7 @@ 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 POST /api/v1/accounts/:account_id/agents +POST /api/v1/accounts/:account_id/agents/:agent_id/reset_password POST /api/v1/accounts/:account_id/agents/bulk_assign POST /api/v1/accounts/:account_id/agents/bulk_create POST /api/v1/accounts/:account_id/agents/bulk_unassign @@ -633,10 +661,14 @@ POST /api/v1/accounts/:account_id/captain/assistants/:assistant_id/documents/ POST /api/v1/accounts/:account_id/captain/assistants/:assistant_id/inboxes POST /api/v1/accounts/:account_id/captain/assistants/:assistant_id/playground POST /api/v1/accounts/:account_id/captain/assistants/:assistant_id/scenarios/ +POST /api/v1/accounts/:account_id/captain/auto_reply_rules +POST /api/v1/accounts/:account_id/captain/auto_reply_rules/ +POST /api/v1/accounts/:account_id/captain/auto_reply_rules/evaluate POST /api/v1/accounts/:account_id/captain/bulk_actions/ POST /api/v1/accounts/:account_id/captain/conversation_insights/:conversation_id/analyze_participants POST /api/v1/accounts/:account_id/captain/conversation_insights/:conversation_id/extract_action_items POST /api/v1/accounts/:account_id/captain/conversation_insights/:conversation_id/suggest_labels +POST /api/v1/accounts/:account_id/captain/conversations/:conversation_id/respond 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 @@ -650,6 +682,9 @@ POST /api/v1/accounts/:account_id/captain/custom_tools/ POST /api/v1/accounts/:account_id/captain/custom_tools/test POST /api/v1/accounts/:account_id/captain/documents/ POST /api/v1/accounts/:account_id/captain/documents/:document_id/sync +POST /api/v1/accounts/:account_id/captain/message_reports +POST /api/v1/accounts/:account_id/captain/rag/index/:response_id +POST /api/v1/accounts/:account_id/captain/rag/query POST /api/v1/accounts/:account_id/captain/scenarios/ POST /api/v1/accounts/:account_id/captain/tasks/follow_up POST /api/v1/accounts/:account_id/captain/tasks/label_suggestion @@ -740,6 +775,7 @@ POST /api/v1/accounts/:account_id/inboxes/:inbox_id/members POST /api/v1/accounts/:account_id/inboxes/:inbox_id/register_webhook POST /api/v1/accounts/:account_id/inboxes/:inbox_id/reset_secret POST /api/v1/accounts/:account_id/inboxes/:inbox_id/set_agent_bot +POST /api/v1/accounts/:account_id/inboxes/:inbox_id/set_inbound_calls POST /api/v1/accounts/:account_id/inboxes/:inbox_id/sync_templates POST /api/v1/accounts/:account_id/inboxes/web_widget POST /api/v1/accounts/:account_id/instagram/authorization @@ -827,7 +863,6 @@ POST /api/v1/auth/mfa/enable POST /api/v1/auth/mfa/verify POST /api/v1/auth/oauth/callback POST /api/v1/auth/refresh -POST /api/v1/auth/register POST /api/v1/auth/reset_password POST /api/v1/auth/switch_account POST /api/v1/ldap/login @@ -868,10 +903,12 @@ POST /auth/confirmation POST /auth/password POST /auth/sign_in POST /enterprise/api/v1/accounts/:account_id/checkout +POST /enterprise/api/v1/accounts/:account_id/select_billing_currency 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/select_billing_currency POST /enterprise/api/v1/subscription POST /enterprise/api/v1/toggle_deletion POST /enterprise/api/v1/topup_checkout @@ -886,6 +923,8 @@ POST /platform/api/v1/apps POST /platform/api/v1/apps/:id/permissibles POST /platform/api/v1/apps/:id/regenerate_access_token POST /platform/api/v1/banners +POST /platform/api/v1/copilot/config/test +POST /platform/api/v1/copilot/embeddings/reindex POST /platform/api/v1/installation_configs POST /platform/api/v1/users POST /platform/api/v1/users/:id/login @@ -903,6 +942,7 @@ POST /twilio/voice/call/:phone POST /twilio/voice/conference_status/:phone POST /twilio/voice/recording_status/:phone POST /twilio/voice/status/:phone +POST /webhooks/fake/:identifier POST /webhooks/instagram POST /webhooks/line/:line_channel_id POST /webhooks/shopify @@ -938,6 +978,7 @@ PUT /api/v1/accounts/:account_id/canned_responses/:id PUT /api/v1/accounts/:account_id/captain/assistant_responses/:response_id PUT /api/v1/accounts/:account_id/captain/assistants/:assistant_id PUT /api/v1/accounts/:account_id/captain/assistants/:assistant_id/scenarios/:scenario_id +PUT /api/v1/accounts/:account_id/captain/auto_reply_rules/:rule_id PUT /api/v1/accounts/:account_id/captain/custom_tools/:tool_id PUT /api/v1/accounts/:account_id/captain/preferences PUT /api/v1/accounts/:account_id/captain/preferences/ @@ -952,6 +993,8 @@ PUT /api/v1/accounts/:account_id/conversations/:conversation_id/messages/:messag PUT /api/v1/accounts/:account_id/conversations/:conversation_id/participants PUT /api/v1/accounts/:account_id/conversations/:conversation_id/participants/ PUT /api/v1/accounts/:account_id/conversations/:conversation_id/whatsapp_calls/:call_id +PUT /api/v1/accounts/:account_id/copilot/config +PUT /api/v1/accounts/:account_id/copilot/config/ PUT /api/v1/accounts/:account_id/custom_attribute_definitions/:id PUT /api/v1/accounts/:account_id/custom_filters/:id PUT /api/v1/accounts/:account_id/custom_roles/:id @@ -1002,9 +1045,10 @@ PUT /platform/api/v1/agent_bots/:id PUT /platform/api/v1/agent_bots/:id/avatar PUT /platform/api/v1/apps/:id PUT /platform/api/v1/banners/:id +PUT /platform/api/v1/copilot/config PUT /platform/api/v1/installation_configs/:id 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: 1009 +TOTAL: 1053 diff --git a/docs/parity/route-parity.md b/docs/parity/route-parity.md index ac825f4c..88900cad 100644 --- a/docs/parity/route-parity.md +++ b/docs/parity/route-parity.md @@ -3,11 +3,11 @@ Generated from: - GoChat route dump: `docs/parity/gochat-routes.txt` -- Chatwoot route source: `reference/chatwoot/config/routes.rb` +- Chatwoot route source: `docs/chatwoot/config/routes.rb` -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`. +This report covers tracked frontend-critical Chatwoot 4.15.1 routes from `docs/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: 435 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 444 tracked critical routes. +Summary: 447 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 456 tracked critical routes. ## Missing Critical Routes @@ -93,6 +93,7 @@ These routes exist with equivalent method and path shape but different parameter | DELETE | `/api/v1/notification_subscriptions` | `/api/v1/notification_subscriptions` | `api/v1/notification_subscriptions#destroy` | `routes.rb:440` | exact | | DELETE | `/api/v1/profile/avatar` | `/api/v1/profile/avatar` | `api/v1/profiles#avatar` | `routes.rb:422` | exact | | DELETE | `/api/v1/profile/mfa` | `/api/v1/profile/mfa` | `api/v1/profile/mfa#destroy` | `routes.rb:433` | exact | +| DELETE | `/api/v1/profile/sessions/:id` | `/api/v1/profile/sessions/:id` | `api/v1/profile/sessions#destroy` | `routes.rb:445` | exact | | DELETE | `/api/v1/widget/labels/:label_id` | `/api/v1/widget/labels/:label_id` | `api/v1/widget/labels#destroy` | `routes.rb:464` | exact | | GET | `/.well-known/apple-app-site-association` | `/.well-known/apple-app-site-association` | `apple_app#site_association` | `routes.rb:658` | exact | | GET | `/.well-known/assetlinks.json` | `/.well-known/assetlinks.json` | `android_app#assetlinks` | `routes.rb:657` | exact | @@ -116,14 +117,18 @@ These routes exist with equivalent method and path shape but different parameter | GET | `/api/v1/accounts/:account_id/automation_rules/:automation_id` | `/api/v1/accounts/:account_id/automation_rules/:automation_id` | `api/v1/accounts/automation_rules#show` | `routes.rb:115` | exact | | GET | `/api/v1/accounts/:account_id/cache_keys` | `/api/v1/accounts/:account_id/cache_keys` | `api/v1/accounts#cache_keys` | `routes.rb:50` | exact | | GET | `/api/v1/accounts/:account_id/callbacks/register_facebook_page` | `/api/v1/accounts/:account_id/callbacks/register_facebook_page` | `api/v1/accounts/callbacks#register_facebook_page` | `routes.rb:108-109` | exact | +| GET | `/api/v1/accounts/:account_id/calls` | `/api/v1/accounts/:account_id/calls` | `api/v1/accounts/calls#index` | `routes.rb:243` | exact | | GET | `/api/v1/accounts/:account_id/canned_responses/` | `/api/v1/accounts/:account_id/canned_responses/` | `api/v1/accounts/canned_responses#index` | `routes.rb:114` | exact | | GET | `/api/v1/accounts/:account_id/captain/assistant_responses/` | `/api/v1/accounts/:account_id/captain/assistant_responses/` | `api/v1/accounts/captain/assistant_responses#index` | `routes.rb:74` | exact | | GET | `/api/v1/accounts/:account_id/captain/assistant_responses/:response_id` | `/api/v1/accounts/:account_id/captain/assistant_responses/:response_id` | `api/v1/accounts/captain/assistant_responses#show` | `routes.rb:74` | exact | | GET | `/api/v1/accounts/:account_id/captain/assistants/` | `/api/v1/accounts/:account_id/captain/assistants/` | `api/v1/accounts/captain/assistants#index` | `routes.rb:64` | exact | | GET | `/api/v1/accounts/:account_id/captain/assistants/:assistant_id` | `/api/v1/accounts/:account_id/captain/assistants/:assistant_id` | `api/v1/accounts/captain/assistants#show` | `routes.rb:64` | exact | +| GET | `/api/v1/accounts/:account_id/captain/assistants/:assistant_id/drilldown` | `/api/v1/accounts/:account_id/captain/assistants/:assistant_id/drilldown` | `api/v1/accounts/captain/assistants#drilldown` | `routes.rb:71` | exact | | GET | `/api/v1/accounts/:account_id/captain/assistants/:assistant_id/inboxes` | `/api/v1/accounts/:account_id/captain/assistants/:assistant_id/inboxes` | `api/v1/accounts/captain/assistants/inboxes#index` | `routes.rb:71` | exact | | GET | `/api/v1/accounts/:account_id/captain/assistants/:assistant_id/scenarios/` | `/api/v1/accounts/:account_id/captain/assistants/:assistant_id/scenarios/` | `api/v1/accounts/captain/assistants/scenarios#index` | `routes.rb:72` | exact | | GET | `/api/v1/accounts/:account_id/captain/assistants/:assistant_id/scenarios/:scenario_id` | `/api/v1/accounts/:account_id/captain/assistants/:assistant_id/scenarios/:scenario_id` | `api/v1/accounts/captain/assistants/scenarios#show` | `routes.rb:72` | exact | +| GET | `/api/v1/accounts/:account_id/captain/assistants/:assistant_id/stats` | `/api/v1/accounts/:account_id/captain/assistants/:assistant_id/stats` | `api/v1/accounts/captain/assistants#stats` | `routes.rb:69` | exact | +| GET | `/api/v1/accounts/:account_id/captain/assistants/:assistant_id/summary` | `/api/v1/accounts/:account_id/captain/assistants/:assistant_id/summary` | `api/v1/accounts/captain/assistants#summary` | `routes.rb:70` | exact | | GET | `/api/v1/accounts/:account_id/captain/assistants/tools` | `/api/v1/accounts/:account_id/captain/assistants/tools` | `api/v1/accounts/captain/assistants#tools` | `routes.rb:69` | exact | | GET | `/api/v1/accounts/:account_id/captain/copilot_threads/` | `/api/v1/accounts/:account_id/captain/copilot_threads/` | `api/v1/accounts/captain/copilot_threads#index` | `routes.rb:76` | exact | | GET | `/api/v1/accounts/:account_id/captain/copilot_threads/:thread_id/copilot_messages/` | `/api/v1/accounts/:account_id/captain/copilot_threads/:thread_id/copilot_messages/` | `api/v1/accounts/captain/copilot_messages#index` | `routes.rb:77` | exact | @@ -191,6 +196,7 @@ These routes exist with equivalent method and path shape but different parameter | GET | `/api/v1/accounts/:account_id/notification_settings/` | `/api/v1/accounts/:account_id/notification_settings/` | `api/v1/accounts/notification_settings#show` | `routes.rb:293` | exact | | GET | `/api/v1/accounts/:account_id/notifications/` | `/api/v1/accounts/:account_id/notifications/` | `api/v1/accounts/notifications#index` | `routes.rb:283` | exact | | GET | `/api/v1/accounts/:account_id/notifications/unread_count` | `/api/v1/accounts/:account_id/notifications/unread_count` | `api/v1/accounts/notifications#unread_count` | `routes.rb:286` | exact | +| GET | `/api/v1/accounts/:account_id/onboarding/help_center_generation` | `/api/v1/accounts/:account_id/onboarding/help_center_generation` | `api/v1/accounts/onboardings#help_center_generation` | `routes.rb:59` | exact | | GET | `/api/v1/accounts/:account_id/portals` | `/api/v1/accounts/:account_id/portals` | `api/v1/accounts/portals#index` | `routes.rb:385` | exact | | GET | `/api/v1/accounts/:account_id/portals/:portal_id` | `/api/v1/accounts/:account_id/portals/:portal_id` | `api/v1/accounts/portals#show` | `routes.rb:385` | exact | | GET | `/api/v1/accounts/:account_id/portals/:portal_id/articles` | `/api/v1/accounts/:account_id/portals/:portal_id/articles` | `api/v1/accounts/articles#index` | `routes.rb:403` | exact | @@ -213,6 +219,7 @@ These routes exist with equivalent method and path shape but different parameter | GET | `/api/v1/accounts/:account_id/whatsapp_calls/:id` | `/api/v1/accounts/:account_id/whatsapp_calls/:id` | `api/v1/accounts/whatsapp_calls#show` | `routes.rb:237` | exact | | GET | `/api/v1/profile` | `/api/v1/profile` | `api/v1/profiles#show` | `routes.rb:421` | exact | | GET | `/api/v1/profile/mfa` | `/api/v1/profile/mfa` | `api/v1/profile/mfa#show` | `routes.rb:433` | exact | +| GET | `/api/v1/profile/sessions` | `/api/v1/profile/sessions` | `api/v1/profile/sessions#index` | `routes.rb:445` | exact | | GET | `/api/v1/widget/campaigns` | `/api/v1/widget/campaigns` | `api/v1/widget/campaigns#index` | `routes.rb:445` | exact | | GET | `/api/v1/widget/contact` | `/api/v1/widget/contact` | `api/v1/widget/contact#show` | `routes.rb:458` | exact | | GET | `/api/v1/widget/conversations` | `/api/v1/widget/conversations` | `api/v1/widget/conversations#index` | `routes.rb:448` | exact | @@ -228,6 +235,7 @@ These routes exist with equivalent method and path shape but different parameter | GET | `/api/v2/accounts/:account_id/reports/conversation_traffic` | `/api/v2/accounts/:account_id/reports/conversation_traffic` | `api/v2/accounts/reports#conversation_traffic` | `routes.rb:499` | exact | | GET | `/api/v2/accounts/:account_id/reports/conversations` | `/api/v2/accounts/:account_id/reports/conversations` | `api/v2/accounts/reports#conversations` | `routes.rb:497` | exact | | GET | `/api/v2/accounts/:account_id/reports/conversations_summary` | `/api/v2/accounts/:account_id/reports/conversations_summary` | `api/v2/accounts/reports#conversations_summary` | `routes.rb:498` | exact | +| GET | `/api/v2/accounts/:account_id/reports/drilldown` | `/api/v2/accounts/:account_id/reports/drilldown` | `api/v2/accounts/reports#drilldown` | `routes.rb:508` | exact | | GET | `/api/v2/accounts/:account_id/reports/first_response_time_distribution` | `/api/v2/accounts/:account_id/reports/first_response_time_distribution` | `api/v2/accounts/reports#first_response_time_distribution` | `routes.rb:502` | exact | | GET | `/api/v2/accounts/:account_id/reports/inbox_label_matrix` | `/api/v2/accounts/:account_id/reports/inbox_label_matrix` | `api/v2/accounts/reports#inbox_label_matrix` | `routes.rb:501` | exact | | GET | `/api/v2/accounts/:account_id/reports/inboxes` | `/api/v2/accounts/:account_id/reports/inboxes` | `api/v2/accounts/reports#inboxes` | `routes.rb:494` | exact | @@ -244,6 +252,7 @@ These routes exist with equivalent method and path shape but different parameter | GET | `/app` | `/app` | `dashboard#index` | `routes.rb:19` | exact | | GET | `/app/*params` | `/app/*params` | `dashboard#index` | `routes.rb:20` | exact | | GET | `/enterprise/api/v1/accounts/:account_id/limits` | `/enterprise/api/v1/accounts/:account_id/limits` | `enterprise/api/v1/accounts#limits` | `routes.rb:525` | exact | +| GET | `/enterprise/api/v1/accounts/:account_id/topup_options` | `/enterprise/api/v1/accounts/:account_id/topup_options` | `enterprise/api/v1/accounts#topup_options` | `routes.rb:539` | exact | | GET | `/google/callback` | `/google/callback` | `google/callbacks#show` | `routes.rb:650` | exact | | GET | `/hc/:slug` | `/hc/:slug` | `public/api/v1/portals#show` | `routes.rb:590` | exact | | GET | `/hc/:slug/:locale` | `/hc/:slug/:locale` | `public/api/v1/portals#show` | `routes.rb:592` | exact | @@ -318,6 +327,7 @@ These routes exist with equivalent method and path shape but different parameter | POST | `/api/v1/accounts/:account_id/captain/custom_tools/test` | `/api/v1/accounts/:account_id/captain/custom_tools/test` | `api/v1/accounts/captain/custom_tools#test` | `routes.rb:80` | exact | | POST | `/api/v1/accounts/:account_id/captain/documents/` | `/api/v1/accounts/:account_id/captain/documents/` | `api/v1/accounts/captain/documents#create` | `routes.rb:82` | exact | | POST | `/api/v1/accounts/:account_id/captain/documents/:document_id/sync` | `/api/v1/accounts/:account_id/captain/documents/:document_id/sync` | `api/v1/accounts/captain/documents#sync` | `routes.rb:83` | exact | +| POST | `/api/v1/accounts/:account_id/captain/message_reports` | `/api/v1/accounts/:account_id/captain/message_reports` | `api/v1/accounts/captain/message_reports#create` | `routes.rb:80` | exact | | POST | `/api/v1/accounts/:account_id/captain/tasks/follow_up` | `/api/v1/accounts/:account_id/captain/tasks/follow_up` | `api/v1/accounts/captain/tasks#follow_up` | `routes.rb:90` | exact | | POST | `/api/v1/accounts/:account_id/captain/tasks/label_suggestion` | `/api/v1/accounts/:account_id/captain/tasks/label_suggestion` | `api/v1/accounts/captain/tasks#label_suggestion` | `routes.rb:89` | exact | | POST | `/api/v1/accounts/:account_id/captain/tasks/reply_suggestion` | `/api/v1/accounts/:account_id/captain/tasks/reply_suggestion` | `api/v1/accounts/captain/tasks#reply_suggestion` | `routes.rb:88` | exact | @@ -359,6 +369,7 @@ These routes exist with equivalent method and path shape but different parameter | POST | `/api/v1/accounts/:account_id/inboxes/:inbox_id/register_webhook` | `/api/v1/accounts/:account_id/inboxes/:inbox_id/register_webhook` | `api/v1/accounts/inboxes#register_webhook` | `routes.rb:260` | exact | | POST | `/api/v1/accounts/:account_id/inboxes/:inbox_id/reset_secret` | `/api/v1/accounts/:account_id/inboxes/:inbox_id/reset_secret` | `api/v1/accounts/inboxes#reset_secret` | `routes.rb:262` | exact | | POST | `/api/v1/accounts/:account_id/inboxes/:inbox_id/set_agent_bot` | `/api/v1/accounts/:account_id/inboxes/:inbox_id/set_agent_bot` | `api/v1/accounts/inboxes#set_agent_bot` | `routes.rb:256` | exact | +| POST | `/api/v1/accounts/:account_id/inboxes/:inbox_id/set_inbound_calls` | `/api/v1/accounts/:account_id/inboxes/:inbox_id/set_inbound_calls` | `api/v1/accounts/inboxes#set_inbound_calls` | `routes.rb:275` | exact | | POST | `/api/v1/accounts/:account_id/inboxes/:inbox_id/sync_templates` | `/api/v1/accounts/:account_id/inboxes/:inbox_id/sync_templates` | `api/v1/accounts/inboxes#sync_templates` | `routes.rb:258` | exact | | POST | `/api/v1/accounts/:account_id/instagram/authorization` | `/api/v1/accounts/:account_id/instagram/authorization` | `api/v1/accounts/instagram/authorizations#create` | `routes.rb:327` | exact | | POST | `/api/v1/accounts/:account_id/integrations/dyte/add_participant_to_meeting` | `/api/v1/accounts/:account_id/integrations/dyte/add_participant_to_meeting` | `api/v1/accounts/integrations/dyte#add_participant_to_meeting` | `routes.rb:358` | exact | @@ -422,6 +433,7 @@ These routes exist with equivalent method and path shape but different parameter | POST | `/api/v1/widget/messages` | `/api/v1/widget/messages` | `api/v1/widget/messages#create` | `routes.rb:447` | exact | | POST | `/api/v2/accounts/` | `/api/v2/accounts/` | `api/v2/accounts#create` | `routes.rb:478` | exact | | POST | `/enterprise/api/v1/accounts/:account_id/checkout` | `/enterprise/api/v1/accounts/:account_id/checkout` | `enterprise/api/v1/accounts#checkout` | `routes.rb:523` | exact | +| POST | `/enterprise/api/v1/accounts/:account_id/select_billing_currency` | `/enterprise/api/v1/accounts/:account_id/select_billing_currency` | `enterprise/api/v1/accounts#select_billing_currency` | `routes.rb:535` | exact | | POST | `/enterprise/api/v1/accounts/:account_id/subscription` | `/enterprise/api/v1/accounts/:account_id/subscription` | `enterprise/api/v1/accounts#subscription` | `routes.rb:524` | exact | | POST | `/enterprise/api/v1/accounts/:account_id/toggle_deletion` | `/enterprise/api/v1/accounts/:account_id/toggle_deletion` | `enterprise/api/v1/accounts#toggle_deletion` | `routes.rb:526` | exact | | POST | `/enterprise/api/v1/accounts/:account_id/topup_checkout` | `/enterprise/api/v1/accounts/:account_id/topup_checkout` | `enterprise/api/v1/accounts#topup_checkout` | `routes.rb:527` | exact | diff --git a/docs/tracking/01-chatwoot-parity-tracker.md b/docs/tracking/01-chatwoot-parity-tracker.md index 275daf2a..133a6710 100644 --- a/docs/tracking/01-chatwoot-parity-tracker.md +++ b/docs/tracking/01-chatwoot-parity-tracker.md @@ -1,15 +1,15 @@ # Chatwoot Parity Development Plan -Updated: 2026-06-07 +Updated: 2026-07-14 ## Goal -Build GoChat as a Go backend that can directly reuse the frontend from `reference/chatwoot`. The backend API, data contracts, side effects, permissions, and runtime behavior must match the local `reference/chatwoot` repository first. Existing docs are secondary when they conflict with the reference implementation. +Build GoChat as a Go backend that can directly reuse the Chatwoot frontend. The backend API, data contracts, side effects, permissions, and runtime behavior must match the current read-only upstream snapshot under `docs/chatwoot` first. Existing docs and the older `reference/chatwoot` 4.14.1 checkout are secondary when they conflict with the current 4.15.1 snapshot. ## Confirmed Decisions - Frontend: reuse Chatwoot frontend directly. Backend compatibility is mandatory. -- Baseline: `reference/chatwoot` is the source of truth for routes, controllers, models, serializers, jobs, and service behavior. +- Baseline: `docs/chatwoot` 4.15.1 is the current source of truth for routes, controllers, models, serializers, jobs, and service behavior. `reference/chatwoot` 4.14.1 remains a regression fixture only. - Immediate order: keep `go test ./...` green first, then deepen Chatwoot behavior parity. - Search: final implementation must use Meilisearch. DB/LIKE search is not acceptable as the final engine. - Enterprise scope: exclude SSO/SAML/LDAP/OIDC. Include the remaining paid features already present in planning and code: SLA, Audit, CustomRole, AgentCapacity, Captain/Copilot, CSAT, InboxLimit, automation, macros, assignment policies, and related limits/workflows. @@ -20,13 +20,69 @@ This file is the only active execution tracker. The `.hermes/plans/*` files and | Decision axis | Locked rule | Tracking consequence | | --- | --- | --- | -| Product surface | Reuse the `reference/chatwoot` frontend without adapters. | Backend routes, payloads, status codes, pagination, errors, permissions, jobs, and realtime side effects must be Chatwoot-compatible. | -| Reference order | Local `reference/chatwoot` beats prior docs and assumptions. | Every parity slice records inspected reference controllers, Jbuilder views, models, jobs, and frontend API clients. | +| Product surface | Reuse the current `docs/chatwoot` frontend without adapters. | Backend routes, payloads, status codes, pagination, errors, permissions, jobs, and realtime side effects must be Chatwoot-compatible. | +| Reference order | Local `docs/chatwoot` 4.15.1 beats the 4.14.1 regression fixture, prior docs, and assumptions. | Every parity slice records inspected upstream controllers, Jbuilder views, models, jobs, and frontend API clients. | | Test order | Keep the Go suite green before expanding behavior. | A checkpoint cannot close without focused tests, `go test ./...`, and `git diff --check`; docs-only checkpoints require at least `git diff --check`. | | Search | Meilisearch is mandatory for final behavior. | DB search can exist only as explicit local fallback and must not be the production parity path. | | Enterprise | SSO/SAML/LDAP/OIDC are out. All other paid features in this tracker are in. | SLA, Audit, CustomRole, AgentCapacity, Captain/Copilot, CSAT, InboxLimit, automation, macros, assignment policies, and limits stay tracked until verified or explicitly split. | | Commit hygiene | Code, tests, generated route artifacts, and tracker updates land together. | If a route changes, regenerate `docs/parity/gochat-routes.txt`; if the tracked set changes, regenerate `docs/parity/route-parity.md`. | +## 2026-07-13 Chatwoot 4.15.1 Alignment Execution Plan + +### Completion contract + +This alignment cycle is complete only when every row below is implemented, its focused contract tests pass, the generated route manifest is derived from the 4.15.1 source rather than the old hard-coded 4.14 list, full backend tests and the bundled frontend build pass, and an unmodified `docs/chatwoot` frontend completes the enterprise browser smoke without proxied API 4xx/5xx failures. Internal Go tables may remain idiomatic, but every frontend-visible request, response, status, permission, side effect, and realtime payload must match Chatwoot 4.15.1. + +| ID | Missing or drifted contract | GoChat owner | Required behavior | Focused acceptance | End-to-end acceptance | Status | +| --- | --- | --- | --- | --- | --- | --- | --- | +| C415.1 | `GET /api/v1/accounts/:account_id/onboarding/help_center_generation` | account handler/service | Return `{ generation_id, state, articles_count, categories_count }`; derive counts from the account-scoped portal generation when present and stable nil/zero values otherwise. | Handler test freezes exact raw payload, account scope, and admin permission. | 4.15.1 onboarding status component polls successfully without 404. | Verified | +| C415.2 | Captain assistant `stats`, `summary`, and `drilldown` | Captain handler/service | Match range/timezone parsing, metrics payload, summary `{ message }` or 422 error, supported drilldown metrics, and pagination. | Focused handler/service fixtures for all three routes, invalid metric, account scope, and empty data. | Captain overview page and drilldown drawer load without API errors. | Verified | +| C415.3 | `POST /api/v1/accounts/:account_id/captain/message_reports` | Captain handler/service/model | Persist account/conversation/message/user/reason/description; permit only Captain messages; return the 4.15.1 Jbuilder shape. | Migration plus create, invalid sender, foreign account, and validation tests. | Report-Captain-message dialog submits successfully. | Verified | +| C415.4 | `GET /api/v1/accounts/:account_id/calls` | call handler/service | Return the current user's active voice calls using the upstream `_call` serializer, including provider and conversation/contact data. | Empty/list/account/user scope serializer fixtures. | Dashboard call store boot path has no 404 and incoming call card remains functional. | Verified | +| C415.5 | `POST /api/v1/accounts/:account_id/inboxes/:inbox_id/set_inbound_calls` | inbox handler/service | Validate voice-capable WhatsApp/Twilio inbox, persist `channel_config.inbound_calls_enabled`, and return empty 200 while invalid inboxes return Chatwoot-compatible 422. | Enable/disable, unsupported channel, permission, persistence, cache/event tests. | Voice configuration toggles persist after reload. | Verified | +| C415.6 | Profile session index/destroy | profile handler/service/model + auth lifecycle | Track Devise-compatible client sessions, list exact browser/device/location/timestamps/current fields, reject current-session revocation with 422, revoke another client token/session with empty 200. | Migration, login/validate activity, list ordering/current flag, destroy/current guard tests. | Active Sessions page lists and revokes another session without 404. | Verified | +| C415.7 | `GET /api/v2/accounts/:account_id/reports/drilldown` | analytics handler/service | Match metric/bucket/range/type/id/group/business-hours/timezone/page/per-page contract and paginated conversation payload. | Supported metrics, invalid metric, pagination, timezone, and account scope tests. | Reports drilldown drawer loads rows and paginates. | Verified | +| C415.8 | Billing currency selection and top-up options | enterprise account handler/service | Persist valid unlocked currency, reject invalid/locked selection, expose `{ id, currency, options }`, and include `billing_currency` in account payload when set. | Literal and account-scoped route tests for success/error/no-mutation; serializer test. | Billing currency picker and purchase credits modal load without API errors. | Verified | +| C415.9 | `assignable_agents?include_agent_bots=true` and `assignee_type` assignment | assignable/conversation handlers and services | Return User and AgentBot owners with `assignee_type`; assignment must set exactly one of user/bot assignee, validate account accessibility, and serialize the selected resource. | User/bot list and assignment fixtures, invalid/foreign bot, unassign and mutual-exclusion tests. | Conversation assignee dropdown lists bots and persists a bot assignment. | Verified | +| C415.10 | 4.15.1 serializer additions | account, help-center, inbox, team, assignment-policy, SLA serializers/models | Add `icon_color`, `locale_translations`, `billing_currency`, `help_center_generation_id`, `inbound_calls_enabled`, `exclude_older_than_hours`, and SLA due-at fields with upstream null/conditional behavior. | Migration and owner-slice serializer/request round-trip tests for every field. | Categories/teams/help-center/billing/voice/SLA screens render and retain values. | Verified | +| C415.11 | Route parity source drift | `cmd/route_parity`, parity artifacts | Track the current 4.15.1 additions above and fail CI when a frontend-critical route is absent; generated report must name `docs/chatwoot/config/routes.rb`. | Route-parity unit test proves each new route is tracked and a removed route is reported missing. | Fresh dump/report shows zero missing tracked 4.15.1 frontend routes. | Verified | +| C415.12 | Clean 4.15.1 direct-connect gate | smoke harness | Default the upstream smoke source to `docs/chatwoot`, cover the new pages/API calls, WebSocket event delivery, widget boot, and assert no proxied API 4xx/5xx. | Shell syntax, prerequisite check, browser report schema, and explicit new-path assertions. | `scripts/parity_frontend_smoke.sh --enterprise-browser-smoke` passes against PostgreSQL, Redis, Meilisearch, GoChat, and unmodified 4.15.1 Vite. | Verified | + +### 2026-07-14 verification outcome + +All twelve tracked Chatwoot 4.15.1 alignment rows are implemented and verified. The unmodified frontend is directly connectable for the exercised core and enterprise surface; the focused tests below cover the upstream semantics that are not fully observable from the browser gate alone. This is a compatibility claim for the tracked frontend contract, not a claim that every third-party provider integration or unexercised Chatwoot server endpoint has been reproduced. + +| ID | Evidence | Remaining compatibility boundary | +| --- | --- | --- | +| C415.1 | `TestHelpCenterGeneration_ExactRawPayload`; live API smoke passed. | None found in the tracked contract. | +| C415.2 | `TestCaptainAssistantSummaryCachesOnlySuccessfulLLMResponses`, `TestCaptainAssistantStatsAndDrilldownUseExactResolvedReopenCohort`, and `TestCaptainAssistantHandler_OverviewDrilldownAndMessageReportContracts`; Captain overview browser checks passed. | None found in the tracked summary/cache/error, resolved/reopen cohort, or frontend contract. | +| C415.3 | Same focused Captain contract test; live message-report create returned the 4.15.1 shape. | None found in the tracked request/persistence/serializer contract. | +| C415.4 | `TestWhatsAppCallHandler_IndexMatchesChatwootCallsEnvelope`, `TestWhatsAppCallService_IndexScopesAgentsToHandledCalls`, and `TestWhatsAppCallService_IndexMatchesConversationAndReportManagerVisibility`; live calls index passed. | None found in the tracked serializer, inbox membership, accepted-agent, administrator, or `report_manage` visibility contract. | +| C415.5 | `TestInboxService_SetInboundCalls_PersistsVoiceInboxSetting`, `TestInboxService_SetInboundCalls_RejectsUnsupportedInbox`; live disable/enable and voice-settings browser checks passed. | None found in the tracked frontend contract. | +| C415.6 | `TestSessions_IndexAndDestroyMatchChatwootContract` and `TestRefreshTokenStoreScopesTokensByClient`; Active Sessions browser request passed. | None found in the tracked per-client row, refresh-token, current-session guard, or other-session revocation lifecycle. | +| C415.7 | `TestDrilldown_ReturnsChatwootMessageRecordEnvelope`, `TestDrilldown_PaginatesAndScopesAccount`, `TestDrilldown_FirstResponseInfersMessageAndMetricValue`, and `TestDrilldown_DimensionsCountStrategiesAndTimezoneBucketsMatchChatwoot`; live drilldown passed. | None found in the tracked dimension scope, distinct strategy, bot handoff exclusion, invalid grouping, timezone bucket, pagination, or envelope contract. | +| C415.8 | `TestEnterpriseAccountBillingCurrencyAndTopupOptions`, `TestEnterpriseAccountBillingCurrencyRejectsInvalidAndLockedWithoutMutation`, `TestEnterpriseAccountSubscriptionRequiresCurrencySelectionForNewBRLAccount`, `TestEnterpriseBillingWorkerCreatesBRLCustomerAndSubscription`, and `TestEnterpriseBillingWorkerAlwaysClearsCreationFlagOnFailure`; billing browser checks passed. | None found in the tracked feature gate, locale selection, price fallback, durable customer/subscription creation, persisted Stripe attributes, or ensure-style flag cleanup contract. | +| C415.9 | `TestList_IncludeAgentBotsAddsTypedOwners`, `TestAssignAgentBot_MutuallyExclusiveAndAccountScoped`; live bot assignment and restore passed. | None found in the tracked frontend contract. | +| C415.10 | `TestChatwoot415SerializerFields`, `TestChatwoot415AssignmentPolicyFieldRoundTrip`, `TestChatwoot415CaptainMessageSenderSerializer`; affected live pages passed. | None found in the tracked serializer fields. | +| C415.11 | `TestChatwoot415RoutesAreTrackedAndReportedMissing`; generated report has 447 exact, 9 parameter-compatible, and 0 missing routes out of 456 tracked critical routes. | Parameter-compatible entries use equivalent Gin parameter names/path dispatch; they are not missing URLs, but should stay visible in the parity report. | +| C415.12 | `scripts/parity_frontend_smoke.sh --enterprise-browser-smoke` passed against PostgreSQL, Redis, Meilisearch, GoChat, Chrome, and unmodified `docs/chatwoot`; browser report status is `passed` with 59 passed checks and no failed frontend API requests. | The gate proves the covered frontend surface, not every possible Chatwoot workflow or third-party provider callback. | + +### Required final verification + +```bash +cd backend +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/api/v1 ./internal/service ./internal/router ./cmd/route_parity -count=1 +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./... +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go run ./cmd/dump_routes > ../docs/parity/gochat-routes.txt +GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go run ./cmd/route_parity -chatwoot ../docs/chatwoot/config/routes.rb +cd .. +pnpm -C frontend build +CHATWOOT_DIR="$PWD/docs/chatwoot" backend/scripts/parity_frontend_smoke.sh --enterprise-browser-smoke +git diff --check +``` + +Every C415 row is now `Verified` with focused-test evidence. The final direct-connect claim still requires the current full suite, frontend build, route report, and unmodified 4.15.1 browser smoke to remain green together. + Hermes plan landing map: | Hermes source | Landed tracker area | Remaining rule |