diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md new file mode 100644 index 00000000..09aa73a6 --- /dev/null +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -0,0 +1,167 @@ +# Chatwoot Parity Development Plan + +Updated: 2026-06-04 + +## 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. + +## 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. +- 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. + +## Current Baseline + +- `go test ./...` passes. +- Route dump succeeds with `TOTAL: 704`. +- Handler test stability fixes are committed into the baseline before feature parity work continues. +- `.codegraph/` is generated indexing output and is not part of tracked product code. + +## Phase 0: Test And Route Baseline + +Status: done. + +Checklist: + +- [x] Fix `internal/handler/api/v1` route param mismatches around `:account_id`, `:id`, and resource IDs. +- [x] Fix malformed test routes that conflict with Gin wildcard rules. +- [x] Add defensive handling for zero-value services in handler edge tests. +- [x] Align handler error responses where tests encode the expected Chatwoot-compatible status category. +- [x] Keep platform routes bootable and dumpable. +- [x] Verify `go test ./...`. +- [x] Verify `go run ./cmd/dump_routes`. + +Verification commands: + +```bash +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./... +env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go run ./cmd/dump_routes +``` + +## Phase 1: Meilisearch Search Engine + +Status: next. + +Source material: + +- `.hermes/plans/2025-05-24-global-search-meilisearch.md` +- `reference/chatwoot` search controllers, models, and indexing behavior +- Current local packages under `internal/search`, `internal/handler/api/v1/search_handler.go`, and entity repositories + +Checklist: + +- [ ] Define a stable `SearchEngine` interface for Meilisearch-backed search and indexing. +- [ ] Add search config for engine, host, API key, and index prefix. +- [ ] Implement Meilisearch client wrapper with index bootstrapping and settings. +- [ ] Define per-entity documents for conversations, messages, contacts, companies, articles, and help-center content as required by Chatwoot frontend behavior. +- [ ] Wire create/update/delete hooks from services into indexing. +- [ ] Add batch reindex command for existing data. +- [ ] Keep DB search only as explicit development fallback, not as final production mode. +- [ ] Add tests with a mocked search engine and integration hooks that can run without a live Meilisearch instance. +- [ ] Document required Meilisearch environment variables and local startup flow. + +Acceptance: + +- Global search endpoint and entity search endpoints return Chatwoot-compatible payloads. +- Search results are account-scoped. +- Search indexing survives entity updates and deletes. +- `go test ./...` stays green. + +## Phase 2: Route And Controller Parity Audit + +Status: planned. + +Checklist: + +- [ ] Dump Chatwoot routes from `reference/chatwoot`. +- [ ] Dump GoChat routes with `cmd/dump_routes`. +- [ ] Build a tracked route parity table covering method, path, controller, auth scope, request params, and response serializer. +- [ ] Prioritize frontend-critical routes used by the Chatwoot web app. +- [ ] Patch route names and wildcard params where Gin constraints require different internal names, while preserving external URLs. +- [ ] Add regression tests for route groups that previously conflicted. + +Acceptance: + +- Route gap report is generated and checked in. +- All currently implemented routes boot without panic. +- Missing frontend-critical routes have tickets or implementation tasks. + +## Phase 3: Data And Serializer Parity + +Status: planned. + +Checklist: + +- [ ] Compare key Chatwoot serializers/entities with Go response payloads. +- [ ] Align account, user, inbox, conversation, message, contact, company, team, label, canned response, campaign, help center, and notification payload shapes. +- [ ] Verify timestamps, IDs, enum strings, nested objects, pagination metadata, and error envelopes. +- [ ] Add fixture-driven tests for payload compatibility. + +Acceptance: + +- Chatwoot frontend can consume the payloads without adapter code. +- Serializer deviations are documented only where GoChat intentionally differs. + +## Phase 4: Enterprise Feature Completion + +Status: planned. + +Excluded: + +- SSO +- SAML +- LDAP +- OIDC + +Included checklist: + +- [ ] SLA policies and SLA event tracking. +- [ ] Audit logs and admin-readable audit endpoints. +- [ ] Custom roles and permission checks. +- [ ] Agent capacity and assignment limits. +- [ ] Assignment policies and auto-assignment compatibility. +- [ ] Captain/Copilot assistant, custom tools, scenarios, documents, responses, and inbox bindings. +- [ ] CSAT survey response flow, metrics, filters, and review notes. +- [ ] Inbox limits and account/inbox usage enforcement. +- [ ] Automation rules, macros, execution logs, and action side effects. + +Acceptance: + +- Each included feature has routes, persistence, authorization, tests, and Chatwoot-compatible response behavior. +- Unsupported SSO family features are explicitly disabled or omitted without breaking frontend navigation for enabled features. + +## Phase 5: Background Jobs And Integrations + +Status: planned. + +Known hotspots: + +- `internal/worker/worker.go` is still mostly placeholder. +- `internal/automation/action_service.go` has pending webhook/email transcript work. +- `internal/automation/csat_survey_listener.go` has pending CSAT enable/send behavior. +- `internal/auth/webhook_registry.go` has pending signature verification for Facebook/WhatsApp. +- `internal/service/analytics_service.go` has placeholder analytics paths. + +Checklist: + +- [ ] Map Chatwoot jobs/listeners to Go worker responsibilities. +- [ ] Implement durable job dispatch for automation, CSAT, notifications, webhooks, and search indexing. +- [ ] Add retry and failure logging for external calls. +- [ ] Add tests for job enqueueing and idempotency. + +Acceptance: + +- User-visible side effects do not depend on synchronous handler-only execution. +- Failed background work is observable and retryable. + +## Ongoing Tracking Rules + +- Every parity task should name the matching file or behavior in `reference/chatwoot`. +- Every route change should keep `cmd/dump_routes` passing. +- Every phase must keep `go test ./...` green before moving on. +- Test-only route fixes should be limited to malformed tests. Production route changes must preserve external Chatwoot-compatible URLs. +- New search work must target Meilisearch first. diff --git a/internal/automation/csat_survey_service.go b/internal/automation/csat_survey_service.go index 3700fc63..f202d052 100644 --- a/internal/automation/csat_survey_service.go +++ b/internal/automation/csat_survey_service.go @@ -19,6 +19,11 @@ func NewCsatSurveyService(db DBProvider) *CsatSurveyService { return &CsatSurveyService{db: db} } +// Ready reports whether the service has a DB provider configured. +func (s *CsatSurveyService) Ready() bool { + return s != nil && s.db != nil +} + // DB returns the underlying gorm.DB for direct queries (e.g., CSV export lookups). func (s *CsatSurveyService) DB() *gorm.DB { return s.db.DB() @@ -39,9 +44,9 @@ type CsatListFilter struct { // CsatMetrics holds aggregate CSAT metrics. // Reference: Chatwoot GET csat_survey_responses/metrics — total responses, rating distribution, average type CsatMetrics struct { - TotalResponses int `json:"total_responses"` + TotalResponses int `json:"total_responses"` RatingDistribution map[int]int `json:"rating_distribution"` // {1: N, 2: N, ..., 5: N} - AverageRating float64 `json:"average_rating"` + AverageRating float64 `json:"average_rating"` } // GetByID retrieves a CSAT survey response by ID. @@ -150,9 +155,9 @@ func (s *CsatSurveyService) UpdateReviewNotes(ctx context.Context, id uint, note Model(&CsatSurveyResponse{}). Where("id = ?", id). Updates(map[string]interface{}{ - "csat_review_notes": notes, + "csat_review_notes": notes, "review_notes_updated_by_id": updatedBy, - "review_notes_updated_at": &now, + "review_notes_updated_at": &now, }).Error } @@ -217,4 +222,4 @@ func (s *CsatSurveyService) Metrics(ctx context.Context, accountID uint, filter } return metrics, nil -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/account_handler.go b/internal/handler/api/v1/account_handler.go index 7fdc081a..23c04162 100644 --- a/internal/handler/api/v1/account_handler.go +++ b/internal/handler/api/v1/account_handler.go @@ -2,7 +2,6 @@ package v1 import ( "net/http" - "strconv" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/service" @@ -66,13 +65,13 @@ func (h *AccountHandler) List(c *gin.Context) { // @Security ApiKeyAuth // @Router /api/v1/accounts/{id} [get] func (h *AccountHandler) Get(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + id := parseAccountIDParam(c) + if id == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return } - account, err := h.svc.GetByID(c.Request.Context(), uint(id)) + account, err := h.svc.GetByID(c.Request.Context(), id) if err != nil { applogger.L().Errorf("Get account: %v", err) response.AbortWithStatusError(c, http.StatusNotFound, response.ErrAccountNotFound, "account not found") @@ -131,8 +130,8 @@ func (h *AccountHandler) Create(c *gin.Context) { // @Security ApiKeyAuth // @Router /api/v1/accounts/{id} [put] func (h *AccountHandler) Update(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + id := parseAccountIDParam(c) + if id == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return } @@ -143,7 +142,7 @@ func (h *AccountHandler) Update(c *gin.Context) { return } - account, err := h.svc.Update(c.Request.Context(), uint(id), req) + account, err := h.svc.Update(c.Request.Context(), id, req) if err != nil { applogger.L().Errorf("Update account: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update account") @@ -166,13 +165,13 @@ func (h *AccountHandler) Update(c *gin.Context) { // @Security ApiKeyAuth // @Router /api/v1/accounts/{id} [delete] func (h *AccountHandler) Delete(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + id := parseAccountIDParam(c) + if id == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return } - if err := h.svc.Delete(c.Request.Context(), uint(id)); err != nil { + if err := h.svc.Delete(c.Request.Context(), id); err != nil { applogger.L().Errorf("Delete account: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete account") return @@ -185,14 +184,14 @@ func (h *AccountHandler) Delete(c *gin.Context) { // GET /api/v1/accounts/:id/users // Reference: Chatwoot Accounts::AccountUsersController#index func (h *AccountHandler) ListUsers(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return } pg := pagination.Parse(c) - users, total, err := h.svc.ListUsers(c.Request.Context(), uint(accountID), pg.Offset, pg.PerPage) + users, total, err := h.svc.ListUsers(c.Request.Context(), accountID, pg.Offset, pg.PerPage) if err != nil { applogger.L().Errorf("List account users: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list account users") @@ -206,8 +205,8 @@ func (h *AccountHandler) ListUsers(c *gin.Context) { // POST /api/v1/accounts/:id/users // Reference: Chatwoot Accounts::AccountUsersController#create func (h *AccountHandler) AddUser(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return } @@ -218,7 +217,7 @@ func (h *AccountHandler) AddUser(c *gin.Context) { return } - if err := h.svc.AddUser(c.Request.Context(), uint(accountID), req); err != nil { + if err := h.svc.AddUser(c.Request.Context(), accountID, req); err != nil { applogger.L().Errorf("Add user to account: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to add user to account") return @@ -231,19 +230,19 @@ func (h *AccountHandler) AddUser(c *gin.Context) { // DELETE /api/v1/accounts/:id/users/:user_id // Reference: Chatwoot Accounts::AccountUsersController#destroy func (h *AccountHandler) RemoveUser(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return } - userID, err := strconv.ParseUint(c.Param("user_id"), 10, 64) + userID, err := parseUintParam(c, "user_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid user id") return } - if err := h.svc.RemoveUser(c.Request.Context(), uint(accountID), uint(userID)); err != nil { + if err := h.svc.RemoveUser(c.Request.Context(), accountID, userID); err != nil { applogger.L().Errorf("Remove user from account: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to remove user from account") return @@ -271,14 +270,14 @@ func (h *AccountHandler) GetAll(c *gin.Context) { // GET /api/v1/accounts/:id/agents // Reference: Chatwoot app/controllers/api/v1/accounts/agents_controller.rb#index func (h *AccountHandler) GetAgents(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return } pg := pagination.Parse(c) - agents, total, err := h.svc.GetAgents(c.Request.Context(), uint(accountID), pg.Offset, pg.PerPage) + agents, total, err := h.svc.GetAgents(c.Request.Context(), accountID, pg.Offset, pg.PerPage) if err != nil { applogger.L().Errorf("GetAgents for account %d: %v", accountID, err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list agents") @@ -292,8 +291,8 @@ func (h *AccountHandler) GetAgents(c *gin.Context) { // PUT /api/v1/accounts/:id/settings // Reference: Chatwoot AccountsController#update (settings subset) func (h *AccountHandler) UpdateSettings(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + id := parseAccountIDParam(c) + if id == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return } @@ -304,7 +303,7 @@ func (h *AccountHandler) UpdateSettings(c *gin.Context) { return } - account, err := h.svc.UpdateSettings(c.Request.Context(), uint(id), req) + account, err := h.svc.UpdateSettings(c.Request.Context(), id, req) if err != nil { applogger.L().Errorf("Update account settings: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update account settings") @@ -320,14 +319,14 @@ func (h *AccountHandler) UpdateSettings(c *gin.Context) { // UpdateActiveAt updates the active_at timestamp for the current user in an account. // POST /api/v1/accounts/:id/update_active_at func (h *AccountHandler) UpdateActiveAt(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + id := parseAccountIDParam(c) + if id == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return } userID := getUserID(c) - if err := h.svc.UpdateActiveAt(c.Request.Context(), uint(id), userID); err != nil { + if err := h.svc.UpdateActiveAt(c.Request.Context(), id, userID); err != nil { applogger.L().Errorf("UpdateActiveAt for account %d, user %d: %v", id, userID, err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update active_at") return @@ -339,14 +338,14 @@ func (h *AccountHandler) UpdateActiveAt(c *gin.Context) { // CacheKeys returns cache key identifiers for frontend cache invalidation. // GET /api/v1/accounts/:id/cache_keys func (h *AccountHandler) CacheKeys(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + id := parseAccountIDParam(c) + if id == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return } userID := getUserID(c) - keys, err := h.svc.CacheKeys(c.Request.Context(), uint(id), userID) + keys, err := h.svc.CacheKeys(c.Request.Context(), id, userID) if err != nil { applogger.L().Errorf("CacheKeys for account %d, user %d: %v", id, userID, err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to get cache keys") @@ -354,4 +353,4 @@ func (h *AccountHandler) CacheKeys(c *gin.Context) { } response.OK(c, keys) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/assignable_agent_handler.go b/internal/handler/api/v1/assignable_agent_handler.go index dc092dbb..3ed2c87c 100644 --- a/internal/handler/api/v1/assignable_agent_handler.go +++ b/internal/handler/api/v1/assignable_agent_handler.go @@ -13,8 +13,8 @@ import ( // AssignableAgentHandler handles endpoints for finding agents assignable to conversations. // Reference: Chatwoot app/controllers/api/v1/accounts/assignable_agents_controller.rb // Two routes: -// 1. GET /api/v1/accounts/:account_id/assignable_agents (with inbox_ids[] query param) -// 2. GET /api/v1/accounts/:account_id/inboxes/:inbox_id/assignable_agents (single inbox) +// 1. GET /api/v1/accounts/:account_id/assignable_agents (with inbox_ids[] query param) +// 2. GET /api/v1/accounts/:account_id/inboxes/:inbox_id/assignable_agents (single inbox) type AssignableAgentHandler struct { svc *service.AssignableAgentService } @@ -26,12 +26,18 @@ func NewAssignableAgentHandler(svc *service.AssignableAgentService) *AssignableA // List returns agents that can be assigned to conversations in the specified inbox(es). // Supports two modes: -// 1. Standalone resource: GET /api/v1/accounts/:account_id/assignable_agents?inbox_ids[]=1&inbox_ids[]=2 -// (Ref: Chatwoot resources :assignable_agents, only: [:index] — takes inbox_ids[] query param) -// 2. Inbox member action: GET /api/v1/accounts/:account_id/inboxes/:inbox_id/assignable_agents -// (Ref: Chatwoot get :assignable_agents, on: :member on inboxes) +// 1. Standalone resource: GET /api/v1/accounts/:account_id/assignable_agents?inbox_ids[]=1&inbox_ids[]=2 +// (Ref: Chatwoot resources :assignable_agents, only: [:index] — takes inbox_ids[] query param) +// 2. Inbox member action: GET /api/v1/accounts/:account_id/inboxes/:inbox_id/assignable_agents +// (Ref: Chatwoot get :assignable_agents, on: :member on inboxes) +// // 返回带workload信息的agent列表,按workload升序排序(最少conversations优先)。 func (h *AssignableAgentHandler) List(c *gin.Context) { + if h.svc == nil { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "assignable agent service unavailable") + return + } + accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") @@ -70,9 +76,9 @@ func (h *AssignableAgentHandler) List(c *gin.Context) { agents, svcErr := h.svc.GetAssignableAgents(c.Request.Context(), accountID, inboxIDs) if svcErr != nil { - handleServiceError(c, svcErr) + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to list assignable agents") return } response.OK(c, agents) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/auto_reply_rule_handler.go b/internal/handler/api/v1/auto_reply_rule_handler.go index 41eb6477..f35d7764 100644 --- a/internal/handler/api/v1/auto_reply_rule_handler.go +++ b/internal/handler/api/v1/auto_reply_rule_handler.go @@ -40,7 +40,7 @@ func (h *AutoReplyRuleHandler) Create(c *gin.Context) { result, err := h.svc.CreateRule(c.Request.Context(), uint(accountID), &req) if err != nil { applogger.L().Errorf("AutoReplyRuleHandler.Create failed: %v", err) - response.AbortWithStatusError(c, http.StatusInternalServerError, "create_failed", "Failed to create auto-reply rule") + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, "create_failed", "Failed to create auto-reply rule") return } @@ -96,7 +96,7 @@ func (h *AutoReplyRuleHandler) Update(c *gin.Context) { result, err := h.svc.UpdateRule(c.Request.Context(), uint(accountID), uint(ruleID), &req) if err != nil { applogger.L().Errorf("AutoReplyRuleHandler.Update failed: %v", err) - response.AbortWithStatusError(c, http.StatusInternalServerError, "update_failed", "Failed to update auto-reply rule") + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, "update_failed", "Failed to update auto-reply rule") return } @@ -120,7 +120,7 @@ func (h *AutoReplyRuleHandler) Delete(c *gin.Context) { if err := h.svc.DeleteRule(c.Request.Context(), uint(accountID), uint(ruleID)); err != nil { applogger.L().Errorf("AutoReplyRuleHandler.Delete failed: %v", err) - response.AbortWithStatusError(c, http.StatusInternalServerError, "delete_failed", "Failed to delete auto-reply rule") + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, "delete_failed", "Failed to delete auto-reply rule") return } diff --git a/internal/handler/api/v1/automation_rule_handler.go b/internal/handler/api/v1/automation_rule_handler.go index 5cd728f3..9de2ca45 100644 --- a/internal/handler/api/v1/automation_rule_handler.go +++ b/internal/handler/api/v1/automation_rule_handler.go @@ -44,6 +44,11 @@ func (h *AutomationRuleHandler) List(c *gin.Context) { // Get retrieves a single automation rule by ID. // GET /api/v1/accounts/:account_id/automation_rules/:id func (h *AutomationRuleHandler) Get(c *gin.Context) { + if _, err := parseUintParam(c, "account_id"); err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") + return + } + automationID, err := parseUintParam(c, "automation_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") @@ -73,6 +78,10 @@ func (h *AutomationRuleHandler) Create(c *gin.Context) { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } + if rule.Name == "" || rule.EventName == "" { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "name and event_name are required") + return + } rule.AccountID = accountID @@ -87,6 +96,11 @@ func (h *AutomationRuleHandler) Create(c *gin.Context) { // Update updates an existing automation rule. // PUT /api/v1/accounts/:account_id/automation_rules/:id func (h *AutomationRuleHandler) Update(c *gin.Context) { + if _, err := parseUintParam(c, "account_id"); err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") + return + } + automationID, err := parseUintParam(c, "automation_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") @@ -98,6 +112,10 @@ func (h *AutomationRuleHandler) Update(c *gin.Context) { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } + if rule.Name == "" || rule.EventName == "" { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "name and event_name are required") + return + } rule.ID = automationID @@ -112,6 +130,11 @@ func (h *AutomationRuleHandler) Update(c *gin.Context) { // Delete soft-deletes an automation rule. // DELETE /api/v1/accounts/:account_id/automation_rules/:id func (h *AutomationRuleHandler) Delete(c *gin.Context) { + if _, err := parseUintParam(c, "account_id"); err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") + return + } + automationID, err := parseUintParam(c, "automation_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") @@ -132,6 +155,11 @@ func (h *AutomationRuleHandler) Delete(c *gin.Context) { // Clone duplicates an automation rule. // POST /api/v1/accounts/:account_id/automation_rules/:id/clone func (h *AutomationRuleHandler) Clone(c *gin.Context) { + if _, err := parseUintParam(c, "account_id"); err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") + return + } + automationID, err := parseUintParam(c, "automation_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") @@ -151,6 +179,11 @@ func (h *AutomationRuleHandler) Clone(c *gin.Context) { // POST /api/v1/accounts/:account_id/automation_rules/:id/toggle_active // Reference: Chatwoot does not have a toggle_active endpoint; gochat adds this per M6 requirements. func (h *AutomationRuleHandler) ToggleActive(c *gin.Context) { + if _, err := parseUintParam(c, "account_id"); err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") + return + } + automationID, err := parseUintParam(c, "automation_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") @@ -158,21 +191,24 @@ func (h *AutomationRuleHandler) ToggleActive(c *gin.Context) { } var req struct { - Active bool `json:"active"` + Active *bool `json:"active"` } if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "active field is required") return } + if req.Active == nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "active field is required") + return + } - if svcErr := h.svc.ToggleActive(c.Request.Context(), automationID, req.Active); svcErr != nil { + if svcErr := h.svc.ToggleActive(c.Request.Context(), automationID, *req.Active); svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, gin.H{ "id": strconv.FormatUint(uint64(automationID), 10), - "active": req.Active, + "active": *req.Active, }) } - diff --git a/internal/handler/api/v1/bot_rule_handler.go b/internal/handler/api/v1/bot_rule_handler.go index ae95d25a..8c00497c 100644 --- a/internal/handler/api/v1/bot_rule_handler.go +++ b/internal/handler/api/v1/bot_rule_handler.go @@ -69,7 +69,7 @@ func (h *BotRuleHandler) ListByBot(c *gin.Context) { // Get retrieves a single bot rule by ID. // GET /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/bot_rules/:id func (h *BotRuleHandler) Get(c *gin.Context) { - id, err := parseUintParam(c, "id") + id, err := parseUintAnyParam(c, "rule_id", "id", "agent_bot_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return @@ -99,12 +99,12 @@ func (h *BotRuleHandler) Create(c *gin.Context) { } var req struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` + Name string `json:"name" binding:"required"` + Description string `json:"description"` EventName automation.BotRuleEventType `json:"event_name" binding:"required"` - Conditions automation.Conditions `json:"conditions"` - Actions automation.Actions `json:"actions"` - Status automation.BotRuleStatus `json:"status"` + Conditions automation.Conditions `json:"conditions"` + Actions automation.Actions `json:"actions"` + Status automation.BotRuleStatus `json:"status"` } if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) @@ -137,7 +137,7 @@ func (h *BotRuleHandler) Create(c *gin.Context) { // Update updates an existing bot rule. // PUT /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/bot_rules/:id func (h *BotRuleHandler) Update(c *gin.Context) { - id, err := parseUintParam(c, "id") + id, err := parseUintAnyParam(c, "rule_id", "id", "agent_bot_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return @@ -150,12 +150,12 @@ func (h *BotRuleHandler) Update(c *gin.Context) { } var req struct { - Name string `json:"name"` - Description string `json:"description"` + Name string `json:"name"` + Description string `json:"description"` EventName automation.BotRuleEventType `json:"event_name"` - Conditions automation.Conditions `json:"conditions"` - Actions automation.Actions `json:"actions"` - Status automation.BotRuleStatus `json:"status"` + Conditions automation.Conditions `json:"conditions"` + Actions automation.Actions `json:"actions"` + Status automation.BotRuleStatus `json:"status"` } if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) @@ -193,7 +193,7 @@ func (h *BotRuleHandler) Update(c *gin.Context) { // Delete soft-deletes a bot rule. // DELETE /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/bot_rules/:id func (h *BotRuleHandler) Delete(c *gin.Context) { - id, err := parseUintParam(c, "id") + id, err := parseUintAnyParam(c, "rule_id", "id", "agent_bot_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return @@ -210,7 +210,7 @@ func (h *BotRuleHandler) Delete(c *gin.Context) { // ToggleStatus toggles the active/inactive status of a bot rule. // PATCH /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/bot_rules/:id/status func (h *BotRuleHandler) ToggleStatus(c *gin.Context) { - id, err := parseUintParam(c, "id") + id, err := parseUintAnyParam(c, "rule_id", "id", "agent_bot_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return @@ -235,7 +235,7 @@ func (h *BotRuleHandler) ToggleStatus(c *gin.Context) { // Clone duplicates a bot rule. // POST /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/bot_rules/:id/clone func (h *BotRuleHandler) Clone(c *gin.Context) { - id, err := parseUintParam(c, "id") + id, err := parseUintAnyParam(c, "rule_id", "id", "agent_bot_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return diff --git a/internal/handler/api/v1/bot_rule_handler_test.go b/internal/handler/api/v1/bot_rule_handler_test.go index 91f55d11..a73d48fa 100644 --- a/internal/handler/api/v1/bot_rule_handler_test.go +++ b/internal/handler/api/v1/bot_rule_handler_test.go @@ -67,14 +67,14 @@ func (s *BotRuleHandlerTestSuite) SetupSuite() { // Register routes matching the handler's expected URL patterns accountGroup := r.Group("/api/v1/accounts/:account_id/agent_bots/:agent_bot_id") { - accountGroup.GET("/bot_rules_configs", s.handler.List) // List by account - accountGroup.GET("/bot_rules", s.handler.ListByBot) // List by bot - accountGroup.GET("/bot_rules/:agent_bot_id", s.handler.Get) // Get single - accountGroup.POST("/bot_rules", s.handler.Create) // Create - accountGroup.PUT("/bot_rules/:agent_bot_id", s.handler.Update) // Update - accountGroup.DELETE("/bot_rules/:agent_bot_id", s.handler.Delete) // Delete - accountGroup.PATCH("/bot_rules/:agent_bot_id/status", s.handler.ToggleStatus) // Toggle status - accountGroup.POST("/bot_rules/:agent_bot_id/clone", s.handler.Clone) // Clone + accountGroup.GET("/bot_rules_configs", s.handler.List) // List by account + accountGroup.GET("/bot_rules", s.handler.ListByBot) // List by bot + accountGroup.GET("/bot_rules/:rule_id", s.handler.Get) // Get single + accountGroup.POST("/bot_rules", s.handler.Create) // Create + accountGroup.PUT("/bot_rules/:rule_id", s.handler.Update) // Update + accountGroup.DELETE("/bot_rules/:rule_id", s.handler.Delete) // Delete + accountGroup.PATCH("/bot_rules/:rule_id/status", s.handler.ToggleStatus) // Toggle status + accountGroup.POST("/bot_rules/:rule_id/clone", s.handler.Clone) // Clone } } @@ -563,4 +563,4 @@ func TestBotRuleHandler_NilServicePanics(t *testing.T) { func TestBotRuleHandlerTestSuite(t *testing.T) { suite.Run(t, new(BotRuleHandlerTestSuite)) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/bot_trigger_config_handler.go b/internal/handler/api/v1/bot_trigger_config_handler.go index fb32d954..c14f640c 100644 --- a/internal/handler/api/v1/bot_trigger_config_handler.go +++ b/internal/handler/api/v1/bot_trigger_config_handler.go @@ -69,7 +69,7 @@ func (h *BotTriggerConfigHandler) ListByBot(c *gin.Context) { // Get retrieves a single trigger config by ID. // GET /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/trigger_configs/:id func (h *BotTriggerConfigHandler) Get(c *gin.Context) { - id, err := parseUintParam(c, "id") + id, err := parseUintAnyParam(c, "trigger_config_id", "id", "agent_bot_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return @@ -99,12 +99,12 @@ func (h *BotTriggerConfigHandler) Create(c *gin.Context) { } var req struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - EventName automation.BotRuleEventType `json:"event_name" binding:"required"` - Conditions automation.TriggerConditions `json:"conditions"` - QueryOperator string `json:"query_operator"` - Active bool `json:"active"` + Name string `json:"name" binding:"required"` + Description string `json:"description"` + EventName automation.BotRuleEventType `json:"event_name" binding:"required"` + Conditions automation.TriggerConditions `json:"conditions"` + QueryOperator string `json:"query_operator"` + Active bool `json:"active"` } if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) @@ -143,7 +143,7 @@ func (h *BotTriggerConfigHandler) Create(c *gin.Context) { // Update updates an existing trigger config. // PUT /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/trigger_configs/:id func (h *BotTriggerConfigHandler) Update(c *gin.Context) { - id, err := parseUintParam(c, "id") + id, err := parseUintAnyParam(c, "trigger_config_id", "id", "agent_bot_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return @@ -156,12 +156,12 @@ func (h *BotTriggerConfigHandler) Update(c *gin.Context) { } var req struct { - Name string `json:"name"` - Description string `json:"description"` - EventName automation.BotRuleEventType `json:"event_name"` - Conditions automation.TriggerConditions `json:"conditions"` - QueryOperator string `json:"query_operator"` - Active bool `json:"active"` + Name string `json:"name"` + Description string `json:"description"` + EventName automation.BotRuleEventType `json:"event_name"` + Conditions automation.TriggerConditions `json:"conditions"` + QueryOperator string `json:"query_operator"` + Active bool `json:"active"` } if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) @@ -197,7 +197,7 @@ func (h *BotTriggerConfigHandler) Update(c *gin.Context) { // Delete soft-deletes a trigger config. // DELETE /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/trigger_configs/:id func (h *BotTriggerConfigHandler) Delete(c *gin.Context) { - id, err := parseUintParam(c, "id") + id, err := parseUintAnyParam(c, "trigger_config_id", "id", "agent_bot_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return @@ -214,7 +214,7 @@ func (h *BotTriggerConfigHandler) Delete(c *gin.Context) { // ToggleActive toggles the active state of a trigger config. // PATCH /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/trigger_configs/:id/active func (h *BotTriggerConfigHandler) ToggleActive(c *gin.Context) { - id, err := parseUintParam(c, "id") + id, err := parseUintAnyParam(c, "trigger_config_id", "id", "agent_bot_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return diff --git a/internal/handler/api/v1/bot_trigger_config_handler_test.go b/internal/handler/api/v1/bot_trigger_config_handler_test.go index 0e315d3e..0113536b 100644 --- a/internal/handler/api/v1/bot_trigger_config_handler_test.go +++ b/internal/handler/api/v1/bot_trigger_config_handler_test.go @@ -123,13 +123,13 @@ func (s *BotTriggerConfigHandlerTestSuite) setupRouter() *gin.Engine { { botGroup.GET("", s.handler.ListByBot) botGroup.POST("", s.handler.Create) - botGroup.GET("/:agent_bot_id", s.handler.Get) - botGroup.PUT("/:agent_bot_id", s.handler.Update) - botGroup.DELETE("/:agent_bot_id", s.handler.Delete) + botGroup.GET("/:trigger_config_id", s.handler.Get) + botGroup.PUT("/:trigger_config_id", s.handler.Update) + botGroup.DELETE("/:trigger_config_id", s.handler.Delete) } // ToggleActive route (handler method exists but not in production router yet) - r.PATCH("/api/v1/accounts/:account_id/agent_bots/:agent_bot_id/trigger_configs/:agent_bot_id/active", s.handler.ToggleActive) + r.PATCH("/api/v1/accounts/:account_id/agent_bots/:agent_bot_id/trigger_configs/:trigger_config_id/active", s.handler.ToggleActive) return r } @@ -714,10 +714,10 @@ func (s *BotTriggerConfigHandlerTestSuite) TestToggleActive_MalformedJSON() { // createTestConfig creates a BotTriggerConfig directly via the service for test seeding. func (s *BotTriggerConfigHandlerTestSuite) createTestConfig(name string, eventName automation.BotRuleEventType, active bool) *automation.BotTriggerConfig { config := &automation.BotTriggerConfig{ - AccountID: s.accountID, - AgentBotID: s.agentBotID, - Name: name, - EventName: eventName, + AccountID: s.accountID, + AgentBotID: s.agentBotID, + Name: name, + EventName: eventName, Conditions: automation.TriggerConditions{ {Attribute: "status", FilterOperator: "equal", Values: []string{"open"}, QueryOperator: "and"}, }, @@ -727,4 +727,4 @@ func (s *BotTriggerConfigHandlerTestSuite) createTestConfig(name string, eventNa err := s.svc.Create(context.Background(), config) s.Require().NoError(err) return config -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/captain_assistant_response_handler.go b/internal/handler/api/v1/captain_assistant_response_handler.go index d696bdc8..b6518843 100644 --- a/internal/handler/api/v1/captain_assistant_response_handler.go +++ b/internal/handler/api/v1/captain_assistant_response_handler.go @@ -38,7 +38,7 @@ func (h *CaptainAssistantResponseHandler) ProcessResponse(c *gin.Context) { result, err := h.svc.ProcessResponse(c.Request.Context(), uint(accountID), &req) if err != nil { applogger.L().Errorf("Process assistant response: %v", err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to process assistant response") + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to process assistant response") return } @@ -164,10 +164,10 @@ func (h *CaptainAssistantResponseHandler) Create(c *gin.Context) { } var req struct { - Question string `json:"question" validate:"required"` - Answer string `json:"answer" validate:"required"` - AssistantID uint `json:"assistant_id" validate:"required"` - Status string `json:"status"` + Question string `json:"question" validate:"required"` + Answer string `json:"answer" validate:"required"` + AssistantID uint `json:"assistant_id" validate:"required"` + Status string `json:"status"` } if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) diff --git a/internal/handler/api/v1/captain_custom_tool_crud_handler_test.go b/internal/handler/api/v1/captain_custom_tool_crud_handler_test.go index 632d82c4..94c8a7bf 100644 --- a/internal/handler/api/v1/captain_custom_tool_crud_handler_test.go +++ b/internal/handler/api/v1/captain_custom_tool_crud_handler_test.go @@ -30,10 +30,10 @@ import ( type CaptainCustomToolCRUDTestSuite struct { suite.Suite - router *gin.Engine // unified router with :id=account_id, :tool_id=tool_id - handler *CaptainCustomToolHandler - db *gorm.DB - account *model.Account + router *gin.Engine // unified router with :id=account_id, :tool_id=tool_id + handler *CaptainCustomToolHandler + db *gorm.DB + account *model.Account } func (s *CaptainCustomToolCRUDTestSuite) SetupSuite() { @@ -62,7 +62,7 @@ func (s *CaptainCustomToolCRUDTestSuite) SetupSuite() { // Unified router: :id = account_id, :tool_id = tool_id s.router = gin.New() s.router.RedirectTrailingSlash = false - accGroup := s.router.Group("/api/v1/accounts/:custom_tool_id") + accGroup := s.router.Group("/api/v1/accounts/:account_id") { ctGroup := accGroup.Group("/captain/custom_tools") { @@ -103,7 +103,7 @@ func (s *CaptainCustomToolCRUDTestSuite) makeRequest(method, path string, body i if body != nil { req.Header.Set("Content-Type", "application/json") } -s.router.ServeHTTP(w, req) + s.router.ServeHTTP(w, req) return w } @@ -171,7 +171,7 @@ func (s *CaptainCustomToolCRUDTestSuite) TestCreate_无效JSON返回400() { w := httptest.NewRecorder() req, _ := http.NewRequest("POST", s.accountPath()+"/captain/custom_tools/", bytes.NewReader([]byte("{invalid}"))) req.Header.Set("Content-Type", "application/json") -s.router.ServeHTTP(w, req) + s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } @@ -313,4 +313,4 @@ func (s *CaptainCustomToolCRUDTestSuite) TestDelete_不存在的ID() { func TestCaptainCustomToolCRUDSuite(t *testing.T) { suite.Run(t, new(CaptainCustomToolCRUDTestSuite)) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/captain_custom_tool_handler.go b/internal/handler/api/v1/captain_custom_tool_handler.go index 71b23d4d..8b1c5fb7 100644 --- a/internal/handler/api/v1/captain_custom_tool_handler.go +++ b/internal/handler/api/v1/captain_custom_tool_handler.go @@ -2,7 +2,6 @@ package v1 import ( "net/http" - "strconv" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/service" @@ -25,8 +24,8 @@ func NewCaptainCustomToolHandler(svc *service.CaptainCustomToolService) *Captain // Create creates a new custom tool. // POST /api/v1/accounts/:account_id/captain_custom_tools func (h *CaptainCustomToolHandler) Create(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } @@ -37,7 +36,7 @@ func (h *CaptainCustomToolHandler) Create(c *gin.Context) { return } - tool, err := h.svc.Create(c.Request.Context(), uint(accountID), &req) + tool, err := h.svc.Create(c.Request.Context(), accountID, &req) if err != nil { applogger.L().Errorf("Create captain custom tool: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create custom tool") @@ -50,13 +49,13 @@ func (h *CaptainCustomToolHandler) Create(c *gin.Context) { // Get retrieves a custom tool by ID. // GET /api/v1/accounts/:account_id/captain_custom_tools/:id func (h *CaptainCustomToolHandler) Get(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("tool_id"), 10, 64) + id, err := parseUintAnyParam(c, "tool_id", "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } - tool, err := h.svc.Get(c.Request.Context(), uint(id)) + tool, err := h.svc.Get(c.Request.Context(), id) if err != nil { applogger.L().Errorf("Get captain custom tool: %v", err) response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "custom tool not found") @@ -69,7 +68,7 @@ func (h *CaptainCustomToolHandler) Get(c *gin.Context) { // Update updates an existing custom tool. // PUT /api/v1/accounts/:account_id/captain_custom_tools/:id func (h *CaptainCustomToolHandler) Update(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("tool_id"), 10, 64) + id, err := parseUintAnyParam(c, "tool_id", "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return @@ -81,10 +80,10 @@ func (h *CaptainCustomToolHandler) Update(c *gin.Context) { return } - tool, err := h.svc.Update(c.Request.Context(), uint(id), &req) + tool, err := h.svc.Update(c.Request.Context(), id, &req) if err != nil { applogger.L().Errorf("Update captain custom tool: %v", err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update custom tool") + handleServiceError(c, err) return } @@ -94,13 +93,13 @@ func (h *CaptainCustomToolHandler) Update(c *gin.Context) { // Delete deletes a custom tool. // DELETE /api/v1/accounts/:account_id/captain_custom_tools/:id func (h *CaptainCustomToolHandler) Delete(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("tool_id"), 10, 64) + id, err := parseUintAnyParam(c, "tool_id", "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } - if err := h.svc.Delete(c.Request.Context(), uint(id)); err != nil { + if err := h.svc.Delete(c.Request.Context(), id); err != nil { applogger.L().Errorf("Delete captain custom tool: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete custom tool") return @@ -112,14 +111,14 @@ func (h *CaptainCustomToolHandler) Delete(c *gin.Context) { // List retrieves custom tools for an account. // GET /api/v1/accounts/:account_id/captain_custom_tools func (h *CaptainCustomToolHandler) List(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } p := pagination.Parse(c) - tools, count, err := h.svc.List(c.Request.Context(), uint(accountID), p.Offset, p.PerPage) + tools, count, err := h.svc.List(c.Request.Context(), accountID, p.Offset, p.PerPage) if err != nil { applogger.L().Errorf("List captain custom tools: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list custom tools") @@ -132,7 +131,7 @@ func (h *CaptainCustomToolHandler) List(c *gin.Context) { // ExecuteTool calls the external HTTP endpoint of a custom tool. // POST /api/v1/accounts/:account_id/captain_custom_tools/:id/execute func (h *CaptainCustomToolHandler) ExecuteTool(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("tool_id"), 10, 64) + id, err := parseUintAnyParam(c, "tool_id", "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return @@ -144,7 +143,7 @@ func (h *CaptainCustomToolHandler) ExecuteTool(c *gin.Context) { params = nil } - result, err := h.svc.ExecuteTool(c.Request.Context(), uint(id), params) + result, err := h.svc.ExecuteTool(c.Request.Context(), id, params) if err != nil { applogger.L().Errorf("ExecuteTool: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to execute tool") @@ -157,8 +156,8 @@ func (h *CaptainCustomToolHandler) ExecuteTool(c *gin.Context) { // TestTool tests a custom tool with given parameters. // POST /api/v1/accounts/:account_id/captain/custom_tools/test func (h *CaptainCustomToolHandler) TestTool(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } @@ -169,12 +168,12 @@ func (h *CaptainCustomToolHandler) TestTool(c *gin.Context) { return } - result, err := h.svc.TestTool(c.Request.Context(), uint(accountID), &req) + result, err := h.svc.TestTool(c.Request.Context(), accountID, &req) if err != nil { applogger.L().Errorf("TestTool: %v", err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to test tool") + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to test tool") return } response.OK(c, result) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/captain_custom_tool_test_handler_test.go b/internal/handler/api/v1/captain_custom_tool_test_handler_test.go index a115a518..2156188d 100644 --- a/internal/handler/api/v1/captain_custom_tool_test_handler_test.go +++ b/internal/handler/api/v1/captain_custom_tool_test_handler_test.go @@ -75,7 +75,7 @@ func (s *CaptainCustomToolTestHandlerTestSuite) SetupSuite() { // 设置路由 s.router = gin.New() - accountsGroup := s.router.Group("/api/v1/accounts/:custom_tool_id") + accountsGroup := s.router.Group("/api/v1/accounts/:account_id") { captainGroup := accountsGroup.Group("/captain") { @@ -171,14 +171,14 @@ func (s *CaptainCustomToolTestHandlerTestSuite) TestTestTool_不存在的工具I func (s *CaptainCustomToolTestHandlerTestSuite) TestTestTool_带POST方法和参数() { // 创建一个POST方法的工具 postTool := &model.CaptainCustomTool{ - AccountID: s.account.ID, - Title: "POST测试工具", - Slug: "post-test-tool", - EndpointURL: "https://httpbin.org/post", - HTTPMethod: "POST", - AuthType: model.ToolAuthTypeNone, - RequestTemplate: "{\"message\": \"{{.message}}\"}", - Enabled: true, + AccountID: s.account.ID, + Title: "POST测试工具", + Slug: "post-test-tool", + EndpointURL: "https://httpbin.org/post", + HTTPMethod: "POST", + AuthType: model.ToolAuthTypeNone, + RequestTemplate: "{\"message\": \"{{.message}}\"}", + Enabled: true, } s.Require().NoError(s.db.Create(postTool).Error) @@ -205,4 +205,4 @@ func (s *CaptainCustomToolTestHandlerTestSuite) TestTestTool_带POST方法和参 func TestCaptainCustomToolTestHandlerSuite(t *testing.T) { suite.Run(t, new(CaptainCustomToolTestHandlerTestSuite)) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/category_handler.go b/internal/handler/api/v1/category_handler.go index ceeda7ab..bd2a4531 100644 --- a/internal/handler/api/v1/category_handler.go +++ b/internal/handler/api/v1/category_handler.go @@ -96,7 +96,7 @@ func (h *CategoryHandler) Update(c *gin.Context) { category, err := h.svc.Update(c.Request.Context(), uint(categoryID), &req) if err != nil { applogger.L().Errorf("Update category: %v", err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update category") + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to update category") return } @@ -114,7 +114,7 @@ func (h *CategoryHandler) Delete(c *gin.Context) { if err := h.svc.Delete(c.Request.Context(), uint(categoryID)); err != nil { applogger.L().Errorf("Delete category: %v", err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete category") + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to delete category") return } @@ -164,4 +164,4 @@ func (h *CategoryHandler) Reorder(c *gin.Context) { } response.NoContent(c) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/company_handler_test.go b/internal/handler/api/v1/company_handler_test.go index 4ecf800c..9466b2d6 100644 --- a/internal/handler/api/v1/company_handler_test.go +++ b/internal/handler/api/v1/company_handler_test.go @@ -200,13 +200,13 @@ func (s *CompanyHandlerTestSuite) TestCreate_ValidationError() { // ========== Search ========== func (s *CompanyHandlerTestSuite) TestSearch_NoResult() { - // Note: ILIKE is PostgreSQL-specific and doesn't work with SQLite. - // The handler will return a 500 error from the service layer due to - // SQLite not supporting ILIKE. This is expected in test environments. - // In production with PostgreSQL, this would return 200 with results. w := s.makeRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/companies/search?q=Nonexistent", s.accountID), nil) - // SQLite doesn't support ILIKE, so the handler returns 500 - assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code) + assert.Equal(s.T(), http.StatusOK, w.Code) + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + data := resp["data"].([]interface{}) + assert.Len(s.T(), data, 0) } // ========== Get ========== diff --git a/internal/handler/api/v1/contact_handler.go b/internal/handler/api/v1/contact_handler.go index e727ef9c..7138198c 100644 --- a/internal/handler/api/v1/contact_handler.go +++ b/internal/handler/api/v1/contact_handler.go @@ -12,6 +12,7 @@ import ( "github.com/gochat/gochat/pkg/pagination" "github.com/gochat/gochat/pkg/response" ) + // ContactHandler handles contact-related API endpoints. // Reference: Chatwoot app/controllers/api/v1/contacts_controller.rb type ContactHandler struct { @@ -463,7 +464,7 @@ func (h *ContactHandler) CreateContactInbox(c *gin.Context) { } var req struct { - InboxID uint `json:"inbox_id" binding:"required"` + InboxID uint `json:"inbox_id" binding:"required"` SourceID string `json:"source_id"` } if bindErr := c.ShouldBindJSON(&req); bindErr != nil { @@ -522,6 +523,10 @@ func (h *ContactHandler) Active(c *gin.Context) { perPage := getPageSize(c) offset := (page - 1) * perPage sort := c.DefaultQuery("sort", "") + if !h.svc.Ready() { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list active contacts"}) + return + } contacts, total, svcErr := h.svc.ListActive(c.Request.Context(), accountID, offset, perPage, sort) if svcErr != nil { @@ -628,6 +633,7 @@ func (h *ContactHandler) DeleteCustomAttributes(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "custom attributes deleted"}) } + // Merge two contacts into one. The base contact survives, mergee is deleted. // POST /api/v1/accounts/:id/contacts/merge // Reference: Chatwoot app/controllers/api/v1/accounts/actions/contact_merges_controller.rb diff --git a/internal/handler/api/v1/csat_survey_handler.go b/internal/handler/api/v1/csat_survey_handler.go index 02e0a9ef..b5b844a6 100644 --- a/internal/handler/api/v1/csat_survey_handler.go +++ b/internal/handler/api/v1/csat_survey_handler.go @@ -44,8 +44,8 @@ func (h *CsatSurveyHandler) List(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "csat_survey_responses": responses, "meta": gin.H{ - "count": total, - "page": filter.Page, + "count": total, + "page": filter.Page, "per_page": filter.PageSize, }, }) @@ -96,9 +96,9 @@ func (h *CsatSurveyHandler) UpdateReviewNotes(c *gin.Context) { } response.OK(c, gin.H{ - "id": id, - "csat_review_notes": body.CsatReviewNotes, - "updated_by_id": userID, + "id": id, + "csat_review_notes": body.CsatReviewNotes, + "updated_by_id": userID, }) } @@ -120,6 +120,10 @@ func (h *CsatSurveyHandler) Update(c *gin.Context) { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } + if !h.svc.Ready() { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to update csat survey response") + return + } resp, svcErr := h.svc.Update(c.Request.Context(), id, body.Rating, body.FeedbackMessage, body.CsatReviewNotes) if svcErr != nil { @@ -325,4 +329,4 @@ func sanitizeCSV(s string) string { s = strings.ReplaceAll(s, "\"", "\"\"") s = strings.ReplaceAll(s, "\n", " ") return s -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/draft_message_handler.go b/internal/handler/api/v1/draft_message_handler.go index 3d89f0a0..0928216e 100644 --- a/internal/handler/api/v1/draft_message_handler.go +++ b/internal/handler/api/v1/draft_message_handler.go @@ -154,6 +154,10 @@ func (h *DraftMessageHandler) Search(c *gin.Context) { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "search query 'q' is required") return } + if !h.draftSvc.Ready() { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to search draft messages") + return + } drafts, svcErr := h.draftSvc.Search(c.Request.Context(), accountID, query) if svcErr != nil { @@ -172,6 +176,10 @@ func (h *DraftMessageHandler) Count(c *gin.Context) { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } + if !h.draftSvc.Ready() { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to count draft messages") + return + } count, svcErr := h.draftSvc.Count(c.Request.Context(), accountID) if svcErr != nil { @@ -180,4 +188,4 @@ func (h *DraftMessageHandler) Count(c *gin.Context) { } response.OK(c, gin.H{"count": count}) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/helpers.go b/internal/handler/api/v1/helpers.go index 65ff72de..cef910a0 100644 --- a/internal/handler/api/v1/helpers.go +++ b/internal/handler/api/v1/helpers.go @@ -16,6 +16,33 @@ func parseUintParam(c *gin.Context, param string) (uint, error) { return uint(n), nil } +func parseUintAnyParam(c *gin.Context, params ...string) (uint, error) { + var lastErr error + for _, param := range params { + if c.Param(param) == "" { + continue + } + id, err := parseUintParam(c, param) + if err == nil && id != 0 { + return id, nil + } + return 0, err + } + return 0, lastErr +} + +// parseAccountIDParam accepts both Chatwoot-style :account_id and older local +// tests/routes that still mount the account parameter as :id. +func parseAccountIDParam(c *gin.Context) uint { + if id, err := parseUintParam(c, "account_id"); err == nil && id != 0 { + return id + } + if id, err := parseUintParam(c, "id"); err == nil && id != 0 { + return id + } + return 0 +} + // getAccountID extracts account ID from the request. // Priority: URL param account_id > X-Account-ID header > JWT claims account_id func getAccountID(c *gin.Context) uint { @@ -127,4 +154,4 @@ func getPageSize(c *gin.Context) int { return 100 } return n -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/inbox_agentbot_avatar_campaigns_test.go b/internal/handler/api/v1/inbox_agentbot_avatar_campaigns_test.go index 02748415..75884ec5 100644 --- a/internal/handler/api/v1/inbox_agentbot_avatar_campaigns_test.go +++ b/internal/handler/api/v1/inbox_agentbot_avatar_campaigns_test.go @@ -15,13 +15,13 @@ func setupInboxAgentBotRouter(handler *InboxHandler) *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() r.Use(gin.Recovery()) - r.GET("/api/v1/accounts/:inbox_id/inboxes/:inbox_id/agent_bot", handler.GetAgentBot) - r.DELETE("/api/v1/accounts/:inbox_id/inboxes/:inbox_id/avatar", handler.DeleteAvatar) - r.GET("/api/v1/accounts/:inbox_id/inboxes/:inbox_id/campaigns", handler.ListCampaigns) + r.GET("/api/v1/accounts/:account_id/inboxes/:inbox_id/agent_bot", handler.GetAgentBot) + r.DELETE("/api/v1/accounts/:account_id/inboxes/:inbox_id/avatar", handler.DeleteAvatar) + r.GET("/api/v1/accounts/:account_id/inboxes/:inbox_id/campaigns", handler.ListCampaigns) return r } -// --- GetAgentBot tests --- +// --- GetAgentBot tests --- func TestInboxHandler_GetAgentBot_InvalidAccountID(t *testing.T) { handler := NewInboxHandler(&service.InboxService{}) @@ -57,7 +57,7 @@ func TestInboxHandler_GetAgentBot_ValidIDs_ZeroService(t *testing.T) { assert.True(t, w.Code == http.StatusUnprocessableEntity || w.Code == http.StatusOK) } -// --- DeleteAvatar tests --- +// --- DeleteAvatar tests --- func TestInboxHandler_DeleteAvatar_InvalidAccountID(t *testing.T) { handler := NewInboxHandler(&service.InboxService{}) @@ -93,7 +93,7 @@ func TestInboxHandler_DeleteAvatar_ValidIDs_ZeroService(t *testing.T) { assert.True(t, w.Code == http.StatusUnprocessableEntity || w.Code == http.StatusOK) } -// --- ListCampaigns tests --- +// --- ListCampaigns tests --- func TestInboxHandler_ListCampaigns_InvalidAccountID(t *testing.T) { handler := NewInboxHandler(&service.InboxService{}) @@ -127,4 +127,4 @@ func TestInboxHandler_ListCampaigns_ValidIDs_ZeroService(t *testing.T) { // Zero service will panic on method call; Recovery middleware catches it → 500 assert.True(t, w.Code == http.StatusUnprocessableEntity || w.Code == http.StatusOK) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/inbox_handler.go b/internal/handler/api/v1/inbox_handler.go index 5bcdc6d9..dc322be0 100644 --- a/internal/handler/api/v1/inbox_handler.go +++ b/internal/handler/api/v1/inbox_handler.go @@ -5,6 +5,7 @@ import ( "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/service" + "github.com/gochat/gochat/pkg/response" ) // InboxHandler handles inbox-related API endpoints. @@ -224,8 +225,8 @@ func (h *InboxHandler) Delete(c *gin.Context) { // POST /api/v1/accounts/:id/inboxes/:inbox_id/set_agent_bot // Reference: Chatwoot InboxesController#set_agent_bot func (h *InboxHandler) SetAgentBot(c *gin.Context) { - accountID, err := parseUintParam(c, "id") - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) return } @@ -241,6 +242,10 @@ func (h *InboxHandler) SetAgentBot(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body: " + err.Error()}) return } + if !h.svc.Ready() { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to set agent bot") + return + } binding, svcErr := h.svc.SetAgentBot(c.Request.Context(), accountID, inboxID, req) if svcErr != nil { @@ -261,8 +266,8 @@ func (h *InboxHandler) SetAgentBot(c *gin.Context) { // GET /api/v1/accounts/:id/inboxes/:inbox_id/health // Reference: Chatwoot InboxesController#health func (h *InboxHandler) Health(c *gin.Context) { - accountID, err := parseUintParam(c, "id") - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) return } @@ -273,6 +278,11 @@ func (h *InboxHandler) Health(c *gin.Context) { return } + if !h.svc.Ready() { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to check inbox health") + return + } + result, svcErr := h.svc.Health(c.Request.Context(), accountID, inboxID) if svcErr != nil { handleServiceError(c, svcErr) @@ -286,8 +296,8 @@ func (h *InboxHandler) Health(c *gin.Context) { // POST /api/v1/accounts/:id/inboxes/:inbox_id/sync_templates // Reference: Chatwoot InboxesController#sync_templates func (h *InboxHandler) SyncTemplates(c *gin.Context) { - accountID, err := parseUintParam(c, "id") - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) return } @@ -298,6 +308,11 @@ func (h *InboxHandler) SyncTemplates(c *gin.Context) { return } + if !h.svc.Ready() { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to sync templates") + return + } + templates, svcErr := h.svc.SyncTemplates(c.Request.Context(), accountID, inboxID) if svcErr != nil { handleServiceError(c, svcErr) @@ -311,8 +326,8 @@ func (h *InboxHandler) SyncTemplates(c *gin.Context) { // POST /api/v1/accounts/:id/inboxes/:inbox_id/register_webhook // Reference: Chatwoot InboxesController#register_webhook func (h *InboxHandler) RegisterWebhook(c *gin.Context) { - accountID, err := parseUintParam(c, "id") - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) return } @@ -328,6 +343,10 @@ func (h *InboxHandler) RegisterWebhook(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body: " + err.Error()}) return } + if !h.svc.Ready() { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to register webhook") + return + } svcErr := h.svc.RegisterWebhook(c.Request.Context(), accountID, inboxID, req) if svcErr != nil { @@ -342,8 +361,8 @@ func (h *InboxHandler) RegisterWebhook(c *gin.Context) { // GET /api/v1/accounts/:id/inboxes/:inbox_id/agent_bot // Reference: Chatwoot InboxesController#agent_bot func (h *InboxHandler) GetAgentBot(c *gin.Context) { - accountID, err := parseUintParam(c, "id") - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) return } @@ -354,6 +373,11 @@ func (h *InboxHandler) GetAgentBot(c *gin.Context) { return } + if !h.svc.Ready() { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to get agent bot") + return + } + agentBot, svcErr := h.svc.GetAgentBot(c.Request.Context(), accountID, inboxID) if svcErr != nil { handleServiceError(c, svcErr) @@ -372,8 +396,8 @@ func (h *InboxHandler) GetAgentBot(c *gin.Context) { // DELETE /api/v1/accounts/:id/inboxes/:inbox_id/avatar // Reference: Chatwoot InboxesController#destroy_avatar func (h *InboxHandler) DeleteAvatar(c *gin.Context) { - accountID, err := parseUintParam(c, "id") - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) return } @@ -384,6 +408,11 @@ func (h *InboxHandler) DeleteAvatar(c *gin.Context) { return } + if !h.svc.Ready() { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to delete inbox avatar") + return + } + inbox, svcErr := h.svc.DeleteAvatar(c.Request.Context(), accountID, inboxID) if svcErr != nil { handleServiceError(c, svcErr) @@ -397,8 +426,8 @@ func (h *InboxHandler) DeleteAvatar(c *gin.Context) { // GET /api/v1/accounts/:id/inboxes/:inbox_id/campaigns // Reference: Chatwoot InboxesController#campaigns func (h *InboxHandler) ListCampaigns(c *gin.Context) { - accountID, err := parseUintParam(c, "id") - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) return } @@ -409,6 +438,11 @@ func (h *InboxHandler) ListCampaigns(c *gin.Context) { return } + if !h.svc.Ready() { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to list campaigns") + return + } + campaigns, svcErr := h.svc.ListCampaigns(c.Request.Context(), accountID, inboxID) if svcErr != nil { handleServiceError(c, svcErr) @@ -441,4 +475,4 @@ func (h *InboxHandler) ResetSecret(c *gin.Context) { } c.JSON(http.StatusOK, inbox) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/inbox_handler_test.go b/internal/handler/api/v1/inbox_handler_test.go index 9b7b9156..5213110d 100644 --- a/internal/handler/api/v1/inbox_handler_test.go +++ b/internal/handler/api/v1/inbox_handler_test.go @@ -18,10 +18,10 @@ import ( func setupInboxMemberActionRouter(handler *InboxHandler) *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() - r.POST("/api/v1/accounts/:inbox_id/inboxes/:inbox_id/set_agent_bot", handler.SetAgentBot) - r.GET("/api/v1/accounts/:inbox_id/inboxes/:inbox_id/health", handler.Health) - r.POST("/api/v1/accounts/:inbox_id/inboxes/:inbox_id/sync_templates", handler.SyncTemplates) - r.POST("/api/v1/accounts/:inbox_id/inboxes/:inbox_id/register_webhook", handler.RegisterWebhook) + r.POST("/api/v1/accounts/:account_id/inboxes/:inbox_id/set_agent_bot", handler.SetAgentBot) + r.GET("/api/v1/accounts/:account_id/inboxes/:inbox_id/health", handler.Health) + r.POST("/api/v1/accounts/:account_id/inboxes/:inbox_id/sync_templates", handler.SyncTemplates) + r.POST("/api/v1/accounts/:account_id/inboxes/:inbox_id/register_webhook", handler.RegisterWebhook) return r } @@ -149,4 +149,4 @@ func TestInboxRegisterWebhook_BadInboxID(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) assert.Contains(t, parseJSONError(w.Body.Bytes()), "invalid inbox id") -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/inbox_limit_handler_test.go b/internal/handler/api/v1/inbox_limit_handler_test.go index 3b64511e..e51d1b2c 100644 --- a/internal/handler/api/v1/inbox_limit_handler_test.go +++ b/internal/handler/api/v1/inbox_limit_handler_test.go @@ -71,7 +71,7 @@ func (s *InboxLimitHandlerTestSuite) TestCreate_BadRequest_EmptyBody() { func (s *InboxLimitHandlerTestSuite) TestUpdate_BadRequest_InvalidID() { r := gin.New() - r.PUT("/api/v1/accounts/:account_id/inboxes/:inbox_id/inbox_limits/:inbox_id", s.handler.Update) + r.PUT("/api/v1/accounts/:account_id/inboxes/:inbox_id/inbox_limits/:id", s.handler.Update) w := httptest.NewRecorder() req, _ := http.NewRequest("PUT", fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d/inbox_limits/abc", s.account.ID, s.inbox.ID), bytes.NewBufferString(`{"limit_count":10}`)) @@ -83,7 +83,7 @@ func (s *InboxLimitHandlerTestSuite) TestUpdate_BadRequest_InvalidID() { func (s *InboxLimitHandlerTestSuite) TestDelete_BadRequest_InvalidID() { r := gin.New() - r.DELETE("/api/v1/accounts/:account_id/inboxes/:inbox_id/inbox_limits/:inbox_id", s.handler.Delete) + r.DELETE("/api/v1/accounts/:account_id/inboxes/:inbox_id/inbox_limits/:id", s.handler.Delete) w := httptest.NewRecorder() req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d/inbox_limits/abc", s.account.ID, s.inbox.ID), nil) @@ -110,7 +110,7 @@ func (s *InboxLimitHandlerTestSuite) TestUpdate_Success() { s.Require().NoError(s.db.Create(limit).Error) r := gin.New() - r.PUT("/api/v1/accounts/:account_id/inboxes/:inbox_id/inbox_limits/:inbox_id", s.handler.Update) + r.PUT("/api/v1/accounts/:account_id/inboxes/:inbox_id/inbox_limits/:id", s.handler.Update) w := httptest.NewRecorder() body := `{"type":"max_conversations","value":200}` @@ -126,11 +126,11 @@ func (s *InboxLimitHandlerTestSuite) TestDelete_Success() { s.Require().NoError(s.db.Create(limit).Error) r := gin.New() - r.DELETE("/api/v1/accounts/:account_id/inboxes/:inbox_id/inbox_limits/:inbox_id", s.handler.Delete) + r.DELETE("/api/v1/accounts/:account_id/inboxes/:inbox_id/inbox_limits/:id", s.handler.Delete) w := httptest.NewRecorder() req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d/inbox_limits/%d", s.account.ID, s.inbox.ID, limit.ID), nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/integration_hook_handler.go b/internal/handler/api/v1/integration_hook_handler.go index 1bb00283..c3bf0b2a 100644 --- a/internal/handler/api/v1/integration_hook_handler.go +++ b/internal/handler/api/v1/integration_hook_handler.go @@ -2,6 +2,7 @@ package v1 import ( "net/http" + "strings" "github.com/gin-gonic/gin" @@ -26,6 +27,11 @@ func NewIntegrationHookHandler(svc *service.IntegrationHookService) *Integration // ListApps retrieves all available integration apps. // GET /api/v1/accounts/:account_id/integrations/apps func (h *IntegrationHookHandler) ListApps(c *gin.Context) { + if !h.svc.Ready() { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to list integration apps") + return + } + apps, err := h.svc.ListApps(c.Request.Context()) if err != nil { handleServiceError(c, err) @@ -174,7 +180,11 @@ func (h *IntegrationHookHandler) ProcessHookEvent(c *gin.Context) { } if svcErr := h.svc.ProcessEvent(c.Request.Context(), id, eventData); svcErr != nil { - handleServiceError(c, svcErr) + if strings.Contains(strings.ToLower(svcErr.Error()), "not found") { + response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, svcErr.Error()) + return + } + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, svcErr.Error()) return } response.OK(c, gin.H{"message": "event processed"}) diff --git a/internal/handler/api/v1/message_handler.go b/internal/handler/api/v1/message_handler.go index e9530671..2c3899d9 100644 --- a/internal/handler/api/v1/message_handler.go +++ b/internal/handler/api/v1/message_handler.go @@ -2,6 +2,7 @@ package v1 import ( "net/http" + "strings" "github.com/gin-gonic/gin" @@ -289,8 +290,17 @@ func (h *MessageHandler) Translate(c *gin.Context) { result, svcErr := h.svc.Translate(c.Request.Context(), accountID, messageID, req) if svcErr != nil { - handleServiceError(c, svcErr) + lower := strings.ToLower(svcErr.Error()) + if strings.Contains(lower, "not found") { + response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, svcErr.Error()) + return + } + if strings.Contains(lower, "required") || strings.Contains(lower, "invalid") { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, svcErr.Error()) + return + } + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, svcErr.Error()) return } response.OK(c, result) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/mfa_handler.go b/internal/handler/api/v1/mfa_handler.go index f25a1ca7..91731440 100644 --- a/internal/handler/api/v1/mfa_handler.go +++ b/internal/handler/api/v1/mfa_handler.go @@ -68,7 +68,7 @@ func (h *MFAHandler) EnableMFA(c *gin.Context) { // Check if MFA is already enabled enabled, err := h.mfaService.IsMFAEnabled(userID) if err != nil { - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, err.Error()) + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, err.Error()) return } if enabled { @@ -79,7 +79,7 @@ func (h *MFAHandler) EnableMFA(c *gin.Context) { // Generate new TOTP secret + QR URI secret, qrURI, err := h.mfaService.GenerateTOTPSecret(userID) if err != nil { - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, err.Error()) + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, err.Error()) return } @@ -202,4 +202,4 @@ func (h *MFAHandler) BackupCodes(c *gin.Context) { return } c.JSON(http.StatusOK, gin.H{"backup_codes": codes}) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/platform_account_user_handler.go b/internal/handler/api/v1/platform_account_user_handler.go index 52219e12..74a6eb04 100644 --- a/internal/handler/api/v1/platform_account_user_handler.go +++ b/internal/handler/api/v1/platform_account_user_handler.go @@ -39,8 +39,8 @@ func NewPlatformAccountUserHandler( // Reference: Chatwoot Platform::Api::V1::AccountUsersController#index // Requires: Account must be within PlatformApp's permissible scope. func (h *PlatformAccountUserHandler) Index(c *gin.Context) { - accountID, err := parseUintParam(c, "id") - if err != nil { + accountID, err := parseUintAnyParam(c, "account_id", "id") + if err != nil || accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account ID") return } @@ -68,8 +68,8 @@ func (h *PlatformAccountUserHandler) Index(c *gin.Context) { // Reference: Chatwoot Platform::Api::V1::AccountUsersController#create // Requires: Both account and user must be within PlatformApp's permissible scope. func (h *PlatformAccountUserHandler) Create(c *gin.Context) { - accountID, err := parseUintParam(c, "id") - if err != nil { + accountID, err := parseUintAnyParam(c, "account_id", "id") + if err != nil || accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account ID") return } @@ -124,13 +124,13 @@ func (h *PlatformAccountUserHandler) Create(c *gin.Context) { // Reference: Chatwoot Platform::Api::V1::AccountUsersController#destroy // Requires: Account must be within PlatformApp's permissible scope. func (h *PlatformAccountUserHandler) Destroy(c *gin.Context) { - accountID, err := parseUintParam(c, "id") - if err != nil { + accountID, err := parseUintAnyParam(c, "account_id", "id") + if err != nil || accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account ID") return } - userID, err := parseUintParam(c, "id") + userID, err := parseUintParam(c, "user_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid user ID") return @@ -151,4 +151,4 @@ func (h *PlatformAccountUserHandler) Destroy(c *gin.Context) { } response.NoContent(c) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/platform_e2e_test.go b/internal/handler/api/v1/platform_e2e_test.go index 6bcbfd55..0c5497e7 100644 --- a/internal/handler/api/v1/platform_e2e_test.go +++ b/internal/handler/api/v1/platform_e2e_test.go @@ -76,8 +76,8 @@ func setupPlatformTokenTestE2E(t *testing.T) (*gin.Engine, *repository.Permissib platformGroup.GET("/users", platformUser.List) platformGroup.GET("/users/:id", platformUser.Show) platformGroup.POST("/users", platformUser.Create) - platformGroup.POST("/users/:agent_bot_id/login", platformUser.Login) - platformGroup.POST("/users/:agent_bot_id/token", platformUser.Token) + platformGroup.POST("/users/:id/login", platformUser.Login) + platformGroup.POST("/users/:id/token", platformUser.Token) platformGroup.PATCH("/users/:id", platformUser.Update) platformGroup.DELETE("/users/:id", platformUser.Destroy) @@ -92,12 +92,12 @@ func setupPlatformTokenTestE2E(t *testing.T) (*gin.Engine, *repository.Permissib platformGroup.POST("/agent_bots", platformAgentBot.Create) platformGroup.PUT("/agent_bots/:id", platformAgentBot.Update) platformGroup.DELETE("/agent_bots/:id", platformAgentBot.Destroy) - platformGroup.POST("/agent_bots/:agent_bot_id/delete_avatar", platformAgentBot.DeleteAvatar) + platformGroup.POST("/agent_bots/:id/delete_avatar", platformAgentBot.DeleteAvatar) - // Gin wildcard constraint: nested routes under accounts/:id must use :id (not :account_id) - platformGroup.GET("/accounts/:agent_bot_id/account_users", platformAccountUser.Index) - platformGroup.POST("/accounts/:agent_bot_id/account_users", platformAccountUser.Create) - platformGroup.DELETE("/accounts/:agent_bot_id/account_users/:user_id", platformAccountUser.Destroy) + // Gin wildcard constraint: nested routes under accounts/:id must reuse :id. + platformGroup.GET("/accounts/:id/account_users", platformAccountUser.Index) + platformGroup.POST("/accounts/:id/account_users", platformAccountUser.Create) + platformGroup.DELETE("/accounts/:id/account_users/:user_id", platformAccountUser.Destroy) return engine, permissibleRepo, userRepo, accountRepo } @@ -443,4 +443,4 @@ func TestPlatformAccountUserE2E_Index(t *testing.T) { engine.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/search_handler.go b/internal/handler/api/v1/search_handler.go index e09ff0f2..a56b115a 100644 --- a/internal/handler/api/v1/search_handler.go +++ b/internal/handler/api/v1/search_handler.go @@ -122,7 +122,7 @@ func (h *SearchHandler) SearchConversations(c *gin.Context) { results, total, svcErr := h.svc.SearchConversations(c.Request.Context(), accountID, query, &filter) if svcErr != nil { - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "conversation search failed") + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "conversation search failed") return } @@ -175,7 +175,7 @@ func (h *SearchHandler) SearchMessages(c *gin.Context) { results, total, svcErr := h.svc.SearchMessages(c.Request.Context(), accountID, query, &filter) if svcErr != nil { - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "message search failed") + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "message search failed") return } @@ -222,7 +222,7 @@ func (h *SearchHandler) SearchContacts(c *gin.Context) { results, total, svcErr := h.svc.SearchContacts(c.Request.Context(), accountID, query, &filter) if svcErr != nil { - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "contact search failed") + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "contact search failed") return } @@ -272,7 +272,7 @@ func (h *SearchHandler) SearchArticles(c *gin.Context) { results, total, svcErr := h.svc.SearchArticles(c.Request.Context(), accountID, query, &filter) if svcErr != nil { - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "article search failed") + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "article search failed") return } @@ -280,4 +280,4 @@ func (h *SearchHandler) SearchArticles(c *gin.Context) { "results": results, "by_type": gin.H{"article": total}, }, filter.Page, filter.PerPage, total) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/sla_policy_handler_test.go b/internal/handler/api/v1/sla_policy_handler_test.go index ec1da92f..d6d073e3 100644 --- a/internal/handler/api/v1/sla_policy_handler_test.go +++ b/internal/handler/api/v1/sla_policy_handler_test.go @@ -81,9 +81,9 @@ func setupSlaPolicyTestRouter(handler *SlaPolicyHandler) *gin.Engine { rg := r.Group("/api/v1/accounts/:account_id") rg.GET("/sla_policies", handler.List) rg.POST("/sla_policies", handler.Create) - rg.GET("/sla_policies/:inbox_id", handler.Get) - rg.PUT("/sla_policies/:inbox_id", handler.Update) - rg.DELETE("/sla_policies/:inbox_id", handler.Delete) + rg.GET("/sla_policies/:id", handler.Get) + rg.PUT("/sla_policies/:id", handler.Update) + rg.DELETE("/sla_policies/:id", handler.Delete) rg.GET("/sla_policies/:id/inboxes", handler.ListInboxes) rg.POST("/sla_policies/:id/inboxes", handler.AddInbox) rg.DELETE("/sla_policies/:id/inboxes/:inbox_id", handler.RemoveInbox) @@ -144,9 +144,9 @@ func TestSlaPolicyHandler_Create_Success(t *testing.T) { "sla_policy": map[string]interface{}{ "name": "New SLA", "description": "desc", - "first_response_time_threshold": 30, - "next_response_time_threshold": 60, - "resolution_time_threshold": 480, + "first_response_time_threshold": 30, + "next_response_time_threshold": 60, + "resolution_time_threshold": 480, }, } jsonBody, _ := json.Marshal(body) @@ -189,9 +189,9 @@ func TestSlaPolicyHandler_Create_NoAccountID(t *testing.T) { body := map[string]interface{}{ "sla_policy": map[string]interface{}{ "name": "New SLA", - "first_response_time_threshold": 30, - "next_response_time_threshold": 60, - "resolution_time_threshold": 480, + "first_response_time_threshold": 30, + "next_response_time_threshold": 60, + "resolution_time_threshold": 480, }, } jsonBody, _ := json.Marshal(body) @@ -260,7 +260,7 @@ func TestSlaPolicyHandler_Update_Success(t *testing.T) { body := map[string]interface{}{ "sla_policy": map[string]interface{}{ "name": "Updated Name", - "first_response_time_threshold": 45, + "first_response_time_threshold": 45, }, } jsonBody, _ := json.Marshal(body) @@ -278,7 +278,7 @@ func TestSlaPolicyHandler_Update_NoAccountID(t *testing.T) { r := gin.New() handler, _ := setupSlaPolicyHandlerTest(t) rg := r.Group("/api/v1/accounts") - rg.PUT("/sla_policies/:inbox_id", handler.Update) + rg.PUT("/sla_policies/:id", handler.Update) body := map[string]interface{}{ "sla_policy": map[string]interface{}{ @@ -325,7 +325,7 @@ func TestSlaPolicyHandler_Delete_NoAccountID(t *testing.T) { r := gin.New() handler, _ := setupSlaPolicyHandlerTest(t) rg := r.Group("/api/v1/accounts") - rg.DELETE("/sla_policies/:inbox_id", handler.Delete) + rg.DELETE("/sla_policies/:id", handler.Delete) w := httptest.NewRecorder() req, _ := http.NewRequest("DELETE", "/api/v1/accounts/sla_policies/1", nil) diff --git a/internal/model/conversation_participant.go b/internal/model/conversation_participant.go index d5b30fcd..ba08ef7d 100644 --- a/internal/model/conversation_participant.go +++ b/internal/model/conversation_participant.go @@ -7,7 +7,7 @@ package model type ConversationParticipant struct { Base AccountID uint `gorm:"index;not null" json:"account_id"` - ConversationID uint `gorm:"index;not null" json:"conversation_id"` + ConversationID uint `gorm:"index;uniqueIndex:idx_conv_participants_user_conv;not null" json:"conversation_id"` UserID uint `gorm:"index;uniqueIndex:idx_conv_participants_user_conv;not null" json:"user_id"` Role string `gorm:"size:50;default:participant" json:"role"` // participant, assignee, watcher @@ -16,4 +16,4 @@ type ConversationParticipant struct { User *User `gorm:"foreignKey:UserID" json:"user,omitempty"` } -func (ConversationParticipant) TableName() string { return "conversation_participants" } \ No newline at end of file +func (ConversationParticipant) TableName() string { return "conversation_participants" } diff --git a/internal/repository/company_repo.go b/internal/repository/company_repo.go index 3d2bfa31..75772ce7 100644 --- a/internal/repository/company_repo.go +++ b/internal/repository/company_repo.go @@ -74,9 +74,9 @@ func (r *CompanyRepo) Search(ctx context.Context, accountID uint, query string, condition = condition.Where("name % ? OR domain % ? OR description % ?", query, query, query) } else { - // ILIKE substring match (default) + // Case-insensitive substring match that works on PostgreSQL and SQLite tests. likeQuery := "%" + query + "%" - condition = condition.Where("name ILIKE ? OR domain ILIKE ? OR description ILIKE ?", + condition = condition.Where("LOWER(name) LIKE LOWER(?) OR LOWER(domain) LIKE LOWER(?) OR LOWER(description) LIKE LOWER(?)", likeQuery, likeQuery, likeQuery) } } @@ -189,4 +189,4 @@ func resolveCompanySort(sort string) string { return order } return "id DESC" -} \ No newline at end of file +} diff --git a/internal/repository/conversation_participant_repo.go b/internal/repository/conversation_participant_repo.go index 1af3fe0c..f41048c0 100644 --- a/internal/repository/conversation_participant_repo.go +++ b/internal/repository/conversation_participant_repo.go @@ -48,6 +48,7 @@ func (r *ConversationParticipantRepo) Delete(ctx context.Context, id uint) error // DeleteByConversationAndUserID removes a specific participant by conversation and user. func (r *ConversationParticipantRepo) DeleteByConversationAndUserID(ctx context.Context, conversationID, userID uint) error { return r.db.WithContext(ctx). + Unscoped(). Where("conversation_id = ? AND user_id = ?", conversationID, userID). Delete(&model.ConversationParticipant{}).Error } @@ -73,4 +74,4 @@ func (r *ConversationParticipantRepo) BatchDelete(ctx context.Context, conversat return r.db.WithContext(ctx). Where("conversation_id = ? AND user_id IN ?", conversationID, userIDs). Delete(&model.ConversationParticipant{}).Error -} \ No newline at end of file +} diff --git a/internal/router/router.go b/internal/router/router.go index c929813c..c35ad3eb 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -27,145 +27,146 @@ 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 { - Auth *v1.AuthHandler - MFA *v1.MFAHandler - SAML *v1.SAMLHandler - Account *v1.AccountHandler - Contact *v1.ContactHandler - Conversation *v1.ConversationHandler - Inbox *v1.InboxHandler - InboxMember *v1.InboxMemberHandler - Message *v1.MessageHandler - Profile *v1.ProfileHandler - Notification *v1.NotificationHandler - PlatformApp *v1.PlatformAppHandler - Team *v1.TeamHandler - CaptainAssistant *v1.CaptainAssistantHandler - CaptainDocument *v1.CaptainDocumentHandler - CaptainScenario *v1.CaptainScenarioHandler - CaptainCustomTool *v1.CaptainCustomToolHandler - CaptainTask *v1.CaptainTaskHandler - CaptainPreference *v1.CaptainPreferenceHandler - CaptainTaskExtended *v1.CaptainTaskExtendedHandler + Auth *v1.AuthHandler + MFA *v1.MFAHandler + SAML *v1.SAMLHandler + Account *v1.AccountHandler + Contact *v1.ContactHandler + Conversation *v1.ConversationHandler + Inbox *v1.InboxHandler + InboxMember *v1.InboxMemberHandler + Message *v1.MessageHandler + Profile *v1.ProfileHandler + Notification *v1.NotificationHandler + PlatformApp *v1.PlatformAppHandler + Team *v1.TeamHandler + CaptainAssistant *v1.CaptainAssistantHandler + CaptainDocument *v1.CaptainDocumentHandler + CaptainScenario *v1.CaptainScenarioHandler + CaptainCustomTool *v1.CaptainCustomToolHandler + CaptainTask *v1.CaptainTaskHandler + CaptainPreference *v1.CaptainPreferenceHandler + CaptainTaskExtended *v1.CaptainTaskExtendedHandler CaptainAssistantResponse *v1.CaptainAssistantResponseHandler - CaptainBulkAction *v1.CaptainBulkActionHandler - BulkAction *v1.BulkActionHandler - Copilot *v1.CopilotHandler - WebWidget *v1.WebWidgetHandler - WebWidgetTheme *v1.WebWidgetThemeHandler - WebWidgetPreChat *v1.WebWidgetPreChatHandler - WebWidgetOffline *v1.WebWidgetOfflineHandler - Analytics *v1.AnalyticsHandler - LiveReport *v1.LiveReportHandler - DashboardApp *v1.DashboardAppHandler - Portal *v1.PortalHandler - Category *v1.CategoryHandler - Article *v1.ArticleHandler - Folder *v1.FolderHandler - PortalMember *v1.PortalMemberHandler - AutomationRule *v1.AutomationRuleHandler - Macro *v1.MacroHandler - CsatSurvey *v1.CsatSurveyHandler - CannedResponse *v1.CannedResponseHandler - PushSubscription *v1.PushSubscriptionHandler - NotificationSetting *v1.NotificationSettingHandler + CaptainBulkAction *v1.CaptainBulkActionHandler + BulkAction *v1.BulkActionHandler + Copilot *v1.CopilotHandler + WebWidget *v1.WebWidgetHandler + WebWidgetTheme *v1.WebWidgetThemeHandler + WebWidgetPreChat *v1.WebWidgetPreChatHandler + WebWidgetOffline *v1.WebWidgetOfflineHandler + Analytics *v1.AnalyticsHandler + LiveReport *v1.LiveReportHandler + DashboardApp *v1.DashboardAppHandler + Portal *v1.PortalHandler + Category *v1.CategoryHandler + Article *v1.ArticleHandler + Folder *v1.FolderHandler + PortalMember *v1.PortalMemberHandler + AutomationRule *v1.AutomationRuleHandler + Macro *v1.MacroHandler + CsatSurvey *v1.CsatSurveyHandler + CannedResponse *v1.CannedResponseHandler + PushSubscription *v1.PushSubscriptionHandler + NotificationSetting *v1.NotificationSettingHandler NotificationSubscription *v1.NotificationSubscriptionHandler - WebhookSubscription *v1.WebhookSubscriptionHandler - TelegramWebhook *webhook.TelegramWebhookHandler - FacebookWebhook *webhook.FacebookWebhookHandler - WhatsAppWebhook *webhook.WhatsAppWebhookHandler - TikTokWebhook *webhook.TikTokWebhookHandler - LineWebhook *webhook.LineWebhookHandler - TwilioWebhook *webhook.TwilioWebhookHandler - AssignmentPolicy *v1.AssignmentPolicyHandler - Label *v1.LabelHandler - Search *v1.SearchHandler - Campaign *v1.CampaignHandler - Widget *widget.WidgetHandler + WebhookSubscription *v1.WebhookSubscriptionHandler + TelegramWebhook *webhook.TelegramWebhookHandler + FacebookWebhook *webhook.FacebookWebhookHandler + WhatsAppWebhook *webhook.WhatsAppWebhookHandler + TikTokWebhook *webhook.TikTokWebhookHandler + LineWebhook *webhook.LineWebhookHandler + TwilioWebhook *webhook.TwilioWebhookHandler + AssignmentPolicy *v1.AssignmentPolicyHandler + Label *v1.LabelHandler + Search *v1.SearchHandler + Campaign *v1.CampaignHandler + Widget *widget.WidgetHandler // M13: SSO/SAML enterprise authentication handlers AccountSamlSettings *v1.AccountSamlSettingsHandler SSOSession *v1.SSOSessionHandler // M13: LDAP/OIDC enterprise authentication handlers - LDAP *v1.LDAPHandler - OIDC *v1.OIDCHandler - SSOMiddleware *auth.SSOMiddleware - InstagramChannel *v1.InstagramChannelHandler - FacebookChannel *v1.FacebookChannelHandler - TwitterChannel *v1.TwitterChannelHandler - MicrosoftChannel *v1.MicrosoftChannelHandler - GoogleChannel *v1.GoogleChannelHandler + LDAP *v1.LDAPHandler + OIDC *v1.OIDCHandler + SSOMiddleware *auth.SSOMiddleware + InstagramChannel *v1.InstagramChannelHandler + FacebookChannel *v1.FacebookChannelHandler + TwitterChannel *v1.TwitterChannelHandler + MicrosoftChannel *v1.MicrosoftChannelHandler + GoogleChannel *v1.GoogleChannelHandler // New channel handlers: TikTok, LINE, Twilio SMS, Email - TikTokChannel *v1.TikTokChannelHandler - LINEChannel *v1.LINEChannelHandler - TwilioSMSChannel *v1.TwilioChannelHandler - EmailChannel *v1.EmailChannelHandler - EmailWebhook *webhook.EmailWebhookHandler + TikTokChannel *v1.TikTokChannelHandler + LINEChannel *v1.LINEChannelHandler + TwilioSMSChannel *v1.TwilioChannelHandler + EmailChannel *v1.EmailChannelHandler + EmailWebhook *webhook.EmailWebhookHandler // M12: AgentBot handlers (platform-level + account-level bots) - AgentBot *v1.AgentBotHandler - InstallationConfig *v1.InstallationConfigHandler - WidgetTest *v1.WidgetTestHandler - AgentBotInbox *v1.AgentBotInboxHandler + AgentBot *v1.AgentBotHandler + InstallationConfig *v1.InstallationConfigHandler + WidgetTest *v1.WidgetTestHandler + AgentBotInbox *v1.AgentBotInboxHandler // P9: AgentBot rule engine + trigger config handlers BotRule *v1.BotRuleHandler BotTriggerConfig *v1.BotTriggerConfigHandler // M12: SSE streaming + conversation insight handlers - SSEStream *v1.SSEStreamHandler + SSEStream *v1.SSEStreamHandler ConversationInsight *v1.ConversationInsightHandler // M4 G3: ContactInbox handler (filter endpoint at account scope) - ContactInboxFilter *v1.ContactInboxHandler + ContactInboxFilter *v1.ContactInboxHandler // Conversation participant + draft message handlers ConversationParticipant *v1.ConversationParticipantHandler DraftMessage *v1.DraftMessageHandler // G4: Company module handler (CRUD + search + nested contacts/conversations/notes) - Company *v1.CompanyHandler + Company *v1.CompanyHandler // Custom attributes + custom filters (attribute definitions, attribute values, saved filters) CustomAttributeDefinition *v1.CustomAttributeDefinitionHandler CustomAttributeValue *v1.CustomAttributeValueHandler CustomFilter *v1.CustomFilterHandler // M11: SLA Policy handler (CRUD + applied SLA metrics/download + inbox associations) SlaPolicy *v1.SlaPolicyHandler -// M11: Assignment Policy V2 handler (enhanced with Type field + inbox join model) + // M11: Assignment Policy V2 handler (enhanced with Type field + inbox join model) AssignmentPolicyV2 *v1.AssignmentPolicyV2Handler // Enterprise: AuditLog, CustomRole, AgentCapacityPolicy, CsatMetrics - Audit *v1.AuditHandler - CustomRole *v1.CustomRoleHandler - AgentCapacity *v1.AgentCapacityHandler - CsatMetrics *v1.CsatMetricsHandler + Audit *v1.AuditHandler + CustomRole *v1.CustomRoleHandler + AgentCapacity *v1.AgentCapacityHandler + CsatMetrics *v1.CsatMetricsHandler // G16: Third-party integration handlers (IntegrationHook CRUD + Slack/Shopify/Linear/Notion) - IntegrationHook *v1.IntegrationHookHandler - SlackIntegration *v1.SlackIntegrationHandler - ShopifyIntegration *v1.ShopifyIntegrationHandler - LinearIntegration *v1.LinearIntegrationHandler - NotionIntegration *v1.NotionIntegrationHandler - PlatformUserSSO *v1.PlatformUserSSOHandler + IntegrationHook *v1.IntegrationHookHandler + SlackIntegration *v1.SlackIntegrationHandler + ShopifyIntegration *v1.ShopifyIntegrationHandler + LinearIntegration *v1.LinearIntegrationHandler + NotionIntegration *v1.NotionIntegrationHandler + PlatformUserSSO *v1.PlatformUserSSOHandler // Platform API AccessToken-authenticated handlers (distinct from SuperAdmin routes) - PlatformUser *v1.PlatformUserHandler - PlatformAccount *v1.PlatformAccountHandler - PlatformAgentBot *v1.PlatformAgentBotHandler - PlatformAccountUser *v1.PlatformAccountUserHandler + PlatformUser *v1.PlatformUserHandler + PlatformAccount *v1.PlatformAccountHandler + PlatformAgentBot *v1.PlatformAgentBotHandler + PlatformAccountUser *v1.PlatformAccountUserHandler // Upload handler (account-level file upload + widget direct uploads) - Upload *v1.UploadHandler + Upload *v1.UploadHandler // Lane B: AssignableAgent (find agents available for assignment to conversations) - AssignableAgent *v1.AssignableAgentHandler - AgentBulk *v1.AgentBulkHandler - Agent *v1.AgentHandler -// Lane C: CSAT template (singular per inbox) + Inbox limits - InboxCsatTemplate *v1.InboxCsatTemplateHandler - InboxLimit *v1.InboxLimitHandler - WorkingHour *v1.WorkingHourHandler + AssignableAgent *v1.AssignableAgentHandler + AgentBulk *v1.AgentBulkHandler + Agent *v1.AgentHandler + // Lane C: CSAT template (singular per inbox) + Inbox limits + InboxCsatTemplate *v1.InboxCsatTemplateHandler + InboxLimit *v1.InboxLimitHandler + WorkingHour *v1.WorkingHourHandler // Delivery status per message per recipient - DeliveryStatus *v1.DeliveryStatusHandler + DeliveryStatus *v1.DeliveryStatusHandler // ReportingEvent handler (P11 — raw analytics events) - ReportingEvent *v1.ReportingEventHandler + ReportingEvent *v1.ReportingEventHandler // WhatsAppCall handler (WhatsApp voice call tracking) - WhatsAppCall *v1.WhatsAppCallHandler + WhatsAppCall *v1.WhatsAppCallHandler // Banner handler (Platform CRUD + account read-only) - Banner *v1.BannerHandler + Banner *v1.BannerHandler // EmailChannelMigration handler (account-scoped create-only) EmailChannelMigration *v1.EmailChannelMigrationHandler // SummaryReport handler (read-only reporting resource — agent/team/inbox/label summaries) SummaryReport *v1.SummaryReportHandler } + // RegisterRoutes sets up all HTTP routes on the Gin engine. // Reference: Chatwoot config/routes.rb — the central routing definition // that maps 327+ routes across API v1, platform, widget, and webhook namespaces. @@ -478,24 +479,24 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // POST /api/v1/accounts/:id/inboxes/:inbox_id/sync_templates — sync WhatsApp templates inboxes.POST("/:inbox_id/sync_templates", h.Inbox.SyncTemplates) // POST /api/v1/accounts/:id/inboxes/:inbox_id/register_webhook — register channel webhook - inboxes.POST("/:inbox_id/register_webhook", h.Inbox.RegisterWebhook) + inboxes.POST("/:inbox_id/register_webhook", h.Inbox.RegisterWebhook) - // GET /api/v1/accounts/:id/inboxes/:inbox_id/agent_bot — get currently active agent bot - inboxes.GET("/:inbox_id/agent_bot", h.Inbox.GetAgentBot) - // DELETE /api/v1/accounts/:id/inboxes/:inbox_id/avatar — remove inbox avatar - inboxes.DELETE("/:inbox_id/avatar", h.Inbox.DeleteAvatar) - // GET /api/v1/accounts/:id/inboxes/:inbox_id/campaigns — list campaigns for inbox - inboxes.GET("/:inbox_id/campaigns", h.Inbox.ListCampaigns) - inboxes.POST("/:inbox_id/reset_secret", h.Inbox.ResetSecret) + // GET /api/v1/accounts/:id/inboxes/:inbox_id/agent_bot — get currently active agent bot + inboxes.GET("/:inbox_id/agent_bot", h.Inbox.GetAgentBot) + // DELETE /api/v1/accounts/:id/inboxes/:inbox_id/avatar — remove inbox avatar + inboxes.DELETE("/:inbox_id/avatar", h.Inbox.DeleteAvatar) + // GET /api/v1/accounts/:id/inboxes/:inbox_id/campaigns — list campaigns for inbox + inboxes.GET("/:inbox_id/campaigns", h.Inbox.ListCampaigns) + inboxes.POST("/:inbox_id/reset_secret", h.Inbox.ResetSecret) - // WebWidget config routes(ref: Chatwoot web_widget_config_controller.rb) + // WebWidget config routes(ref: Chatwoot web_widget_config_controller.rb) // /api/v1/accounts/:id/inboxes/web_widget — create widget inbox inboxes.POST("/web_widget", h.WebWidget.CreateWebWidgetInbox) // /api/v1/accounts/:id/inboxes/:inbox_id/web_widget_config — CRUD widget config inboxes.GET("/:inbox_id/web_widget_config", h.WebWidget.GetWebWidgetConfig) inboxes.PUT("/:inbox_id/web_widget_config", h.WebWidget.UpdateWebWidgetConfig) // /api/v1/accounts/:id/inboxes/:inbox_id/web_widget — delete widget inbox -inboxes.DELETE("/:inbox_id/web_widget", h.WebWidget.DeleteWebWidgetInbox) + inboxes.DELETE("/:inbox_id/web_widget", h.WebWidget.DeleteWebWidgetInbox) // M11: WebWidget theme_config & pre_chat_form admin routes // Extended configuration beyond basic widget_color @@ -510,50 +511,50 @@ inboxes.DELETE("/:inbox_id/web_widget", h.WebWidget.DeleteWebWidgetInbox) // Agents can view pending offline messages and dismiss/convert them inboxes.GET("/:inbox_id/web_widget/offline_messages", h.WebWidgetOffline.ListOfflineMessagesByInbox) - // InboxMember (seat assignment) routes (ref: Chatwoot inbox_members_controller) - // /api/v1/accounts/:id/inboxes/:inbox_id/members — manage agent-to-inbox assignments - inboxes.GET("/:inbox_id/members", h.InboxMember.ListMembers) - inboxes.POST("/:inbox_id/members", h.InboxMember.AddMember) - inboxes.PATCH("/:inbox_id/members/:user_id", h.InboxMember.UpdateMember) - inboxes.PATCH("/:inbox_id/members/update_multiple", h.InboxMember.UpdateMultiple) - inboxes.DELETE("/:inbox_id/members/:user_id", h.InboxMember.RemoveMember) + // InboxMember (seat assignment) routes (ref: Chatwoot inbox_members_controller) + // /api/v1/accounts/:id/inboxes/:inbox_id/members — manage agent-to-inbox assignments + inboxes.GET("/:inbox_id/members", h.InboxMember.ListMembers) + inboxes.POST("/:inbox_id/members", h.InboxMember.AddMember) + inboxes.PATCH("/:inbox_id/members/:user_id", h.InboxMember.UpdateMember) + inboxes.PATCH("/:inbox_id/members/update_multiple", h.InboxMember.UpdateMultiple) + inboxes.DELETE("/:inbox_id/members/:user_id", h.InboxMember.RemoveMember) - // Account-level inbox_members (Chatwoot: resources :inbox_members) - // POST /inbox_members (create), DELETE /inbox_members/:id (destroy) - // These are account-scoped routes for managing inbox assignments - inboxMembers := accountScoped.Group("/inbox_members") - { - inboxMembers.POST("/", h.InboxMember.AddMember) - inboxMembers.DELETE("/:user_id", h.InboxMember.RemoveMember) - } + // Account-level inbox_members (Chatwoot: resources :inbox_members) + // POST /inbox_members (create), DELETE /inbox_members/:id (destroy) + // These are account-scoped routes for managing inbox assignments + inboxMembers := accountScoped.Group("/inbox_members") + { + inboxMembers.POST("/", h.InboxMember.AddMember) + inboxMembers.DELETE("/:user_id", h.InboxMember.RemoveMember) + } - // CSAT template (ref: Chatwoot resource :csat_template, singular per inbox) - // GET /api/v1/accounts/:id/inboxes/:inbox_id/csat_template — show template status - // POST /api/v1/accounts/:id/inboxes/:inbox_id/csat_template — create/update template - // POST /api/v1/accounts/:id/inboxes/:inbox_id/csat_template/analyze — analyze template quality - inboxes.GET("/:inbox_id/csat_template", h.InboxCsatTemplate.Show) - inboxes.POST("/:inbox_id/csat_template", h.InboxCsatTemplate.Create) - inboxes.POST("/:inbox_id/csat_template/analyze", h.InboxCsatTemplate.Analyze) + // CSAT template (ref: Chatwoot resource :csat_template, singular per inbox) + // GET /api/v1/accounts/:id/inboxes/:inbox_id/csat_template — show template status + // POST /api/v1/accounts/:id/inboxes/:inbox_id/csat_template — create/update template + // POST /api/v1/accounts/:id/inboxes/:inbox_id/csat_template/analyze — analyze template quality + inboxes.GET("/:inbox_id/csat_template", h.InboxCsatTemplate.Show) + inboxes.POST("/:inbox_id/csat_template", h.InboxCsatTemplate.Create) + inboxes.POST("/:inbox_id/csat_template/analyze", h.InboxCsatTemplate.Analyze) - // Inbox limits (ref: Chatwoot resources :inbox_limits, create/update/destroy only) - // POST /api/v1/accounts/:id/inboxes/:inbox_id/inbox_limits — create limit - // PATCH /api/v1/accounts/:id/inboxes/:inbox_id/inbox_limits/:id — update limit - // DELETE /api/v1/accounts/:id/inboxes/:inbox_id/inbox_limits/:id — delete limit - inboxes.POST("/:inbox_id/inbox_limits", h.InboxLimit.Create) - inboxes.PATCH("/:inbox_id/inbox_limits/:id", h.InboxLimit.Update) - inboxes.DELETE("/:inbox_id/inbox_limits/:id", h.InboxLimit.Delete) + // Inbox limits (ref: Chatwoot resources :inbox_limits, create/update/destroy only) + // POST /api/v1/accounts/:id/inboxes/:inbox_id/inbox_limits — create limit + // PATCH /api/v1/accounts/:id/inboxes/:inbox_id/inbox_limits/:id — update limit + // DELETE /api/v1/accounts/:id/inboxes/:inbox_id/inbox_limits/:id — delete limit + inboxes.POST("/:inbox_id/inbox_limits", h.InboxLimit.Create) + inboxes.PATCH("/:inbox_id/inbox_limits/:id", h.InboxLimit.Update) + inboxes.DELETE("/:inbox_id/inbox_limits/:id", h.InboxLimit.Delete) - // Working hours (ref: Chatwoot OutOfOffisable concern) - // GET /api/v1/accounts/:id/inboxes/:inbox_id/working_hours — weekly schedule - // PUT /api/v1/accounts/:id/inboxes/:inbox_id/working_hours — update schedule - // GET /api/v1/accounts/:id/inboxes/:inbox_id/out_of_office — check out-of-office status - inboxes.GET("/:inbox_id/working_hours", h.WorkingHour.GetWeeklySchedule) - inboxes.PUT("/:inbox_id/working_hours", h.WorkingHour.UpdateWeeklySchedule) - inboxes.GET("/:inbox_id/out_of_office", h.WorkingHour.IsOutOfOffice) + // Working hours (ref: Chatwoot OutOfOffisable concern) + // GET /api/v1/accounts/:id/inboxes/:inbox_id/working_hours — weekly schedule + // PUT /api/v1/accounts/:id/inboxes/:inbox_id/working_hours — update schedule + // GET /api/v1/accounts/:id/inboxes/:inbox_id/out_of_office — check out-of-office status + inboxes.GET("/:inbox_id/working_hours", h.WorkingHour.GetWeeklySchedule) + inboxes.PUT("/:inbox_id/working_hours", h.WorkingHour.UpdateWeeklySchedule) + inboxes.GET("/:inbox_id/out_of_office", h.WorkingHour.IsOutOfOffice) - // Assignable agents (ref: Chatwoot InboxesController #assignable_agents) - // GET /api/v1/accounts/:id/inboxes/:inbox_id/assignable_agents — list agents assignable to conversations - inboxes.GET("/:inbox_id/assignable_agents", h.AssignableAgent.List) + // Assignable agents (ref: Chatwoot InboxesController #assignable_agents) + // GET /api/v1/accounts/:id/inboxes/:inbox_id/assignable_agents — list agents assignable to conversations + inboxes.GET("/:inbox_id/assignable_agents", h.AssignableAgent.List) } // Contact merge (ref: Chatwoot ContactsController #merge) @@ -578,21 +579,21 @@ inboxes.DELETE("/:inbox_id/web_widget", h.WebWidget.DeleteWebWidgetInbox) // Instagram channel routes (ref: Chatwoot channels/instagram_controller) // Twitter channel routes (G10: OAuth 2.0 + webhook CRC) twChannels := accountScoped.Group("/twitter_channels") - twChannels.GET("/authorization", h.TwitterChannel.Authorization) - twChannels.POST("/oauth_callback", h.TwitterChannel.OAuthCallback) - twChannels.DELETE("/:twitter_id", h.TwitterChannel.Delete) + twChannels.GET("/authorization", h.TwitterChannel.Authorization) + twChannels.POST("/oauth_callback", h.TwitterChannel.OAuthCallback) + twChannels.DELETE("/:twitter_id", h.TwitterChannel.Delete) // Microsoft channel routes (G10: Azure AD OAuth 2.0) msChannels := accountScoped.Group("/microsoft_channels") - msChannels.GET("/authorization", h.MicrosoftChannel.Authorization) - msChannels.POST("/oauth_callback", h.MicrosoftChannel.OAuthCallback) - msChannels.DELETE("/:ms_id", h.MicrosoftChannel.Delete) + msChannels.GET("/authorization", h.MicrosoftChannel.Authorization) + msChannels.POST("/oauth_callback", h.MicrosoftChannel.OAuthCallback) + msChannels.DELETE("/:ms_id", h.MicrosoftChannel.Delete) // Google channel routes (G10: Google OAuth 2.0) goChannels := accountScoped.Group("/google_channels") - goChannels.GET("/authorization", h.GoogleChannel.Authorization) - goChannels.POST("/oauth_callback", h.GoogleChannel.OAuthCallback) - goChannels.DELETE("/:google_id", h.GoogleChannel.Delete) + goChannels.GET("/authorization", h.GoogleChannel.Authorization) + goChannels.POST("/oauth_callback", h.GoogleChannel.OAuthCallback) + goChannels.DELETE("/:google_id", h.GoogleChannel.Delete) igChannels := accountScoped.Group("/instagram_channels") { @@ -601,9 +602,9 @@ inboxes.DELETE("/:inbox_id/web_widget", h.WebWidget.DeleteWebWidgetInbox) } igInboxScoped := accountScoped.Group("/inboxes/:inbox_id/instagram_channels") { - igInboxScoped.GET("/:ig_id", h.InstagramChannel.GetInstagramChannel) - igInboxScoped.PATCH("/:ig_id", h.InstagramChannel.UpdateInstagramChannel) - igInboxScoped.DELETE("/:ig_id", h.InstagramChannel.DeleteInstagramChannel) + igInboxScoped.GET("/:ig_id", h.InstagramChannel.GetInstagramChannel) + igInboxScoped.PATCH("/:ig_id", h.InstagramChannel.UpdateInstagramChannel) + igInboxScoped.DELETE("/:ig_id", h.InstagramChannel.DeleteInstagramChannel) } // === TikTok channel CRUD routes === @@ -670,37 +671,37 @@ inboxes.DELETE("/:inbox_id/web_widget", h.WebWidget.DeleteWebWidgetInbox) // Twitter OAuth + Webhook routes (G10) twOAuth := accountScoped.Group("/twitter") { - twOAuth.GET("/oauth", h.TwitterChannel.Authorization) // OAuth authorize URL - twOAuth.GET("/callback", h.TwitterChannel.OAuthCallbackGET) // OAuth redirect callback (GET, query params) - twOAuth.POST("/webhooks", h.TwitterChannel.RegisterWebhook) // Register webhook - twOAuth.GET("/webhooks", h.TwitterChannel.ListWebhooks) // List webhooks + twOAuth.GET("/oauth", h.TwitterChannel.Authorization) // OAuth authorize URL + twOAuth.GET("/callback", h.TwitterChannel.OAuthCallbackGET) // OAuth redirect callback (GET, query params) + twOAuth.POST("/webhooks", h.TwitterChannel.RegisterWebhook) // Register webhook + twOAuth.GET("/webhooks", h.TwitterChannel.ListWebhooks) // List webhooks } // Instagram OAuth + Webhook routes (G10) igOAuth := accountScoped.Group("/instagram") { - igOAuth.GET("/oauth", h.InstagramChannel.Authorization) // OAuth authorize URL - igOAuth.GET("/callback", h.InstagramChannel.OAuthCallbackGET) // OAuth redirect callback (GET, query params) - igOAuth.POST("/webhooks", h.InstagramChannel.RegisterWebhook) // Register webhook (Facebook Graph API subscribed_apps) - igOAuth.GET("/webhooks", h.InstagramChannel.ListWebhooks) // List webhooks + igOAuth.GET("/oauth", h.InstagramChannel.Authorization) // OAuth authorize URL + igOAuth.GET("/callback", h.InstagramChannel.OAuthCallbackGET) // OAuth redirect callback (GET, query params) + igOAuth.POST("/webhooks", h.InstagramChannel.RegisterWebhook) // Register webhook (Facebook Graph API subscribed_apps) + igOAuth.GET("/webhooks", h.InstagramChannel.ListWebhooks) // List webhooks } // Microsoft OAuth + Webhook routes (G10) msOAuth := accountScoped.Group("/microsoft") { - msOAuth.GET("/oauth", h.MicrosoftChannel.Authorization) // OAuth authorize URL - msOAuth.GET("/callback", h.MicrosoftChannel.OAuthCallbackGET) // OAuth redirect callback (GET, query params) - msOAuth.POST("/webhooks", h.MicrosoftChannel.RegisterWebhook) // Register webhook (Microsoft Graph API subscription) - msOAuth.GET("/webhooks", h.MicrosoftChannel.ListWebhooks) // List webhooks (Microsoft Graph API subscriptions) + msOAuth.GET("/oauth", h.MicrosoftChannel.Authorization) // OAuth authorize URL + msOAuth.GET("/callback", h.MicrosoftChannel.OAuthCallbackGET) // OAuth redirect callback (GET, query params) + msOAuth.POST("/webhooks", h.MicrosoftChannel.RegisterWebhook) // Register webhook (Microsoft Graph API subscription) + msOAuth.GET("/webhooks", h.MicrosoftChannel.ListWebhooks) // List webhooks (Microsoft Graph API subscriptions) } // Google OAuth + Webhook routes (G10) goOAuth := accountScoped.Group("/google") { - goOAuth.GET("/oauth", h.GoogleChannel.Authorization) // OAuth authorize URL - goOAuth.GET("/callback", h.GoogleChannel.OAuthCallbackGET) // OAuth redirect callback (GET, query params) - goOAuth.POST("/webhooks", h.GoogleChannel.RegisterWebhook) // Register webhook (Google Chat API spaces.watch) - goOAuth.GET("/webhooks", h.GoogleChannel.ListWebhooks) // List webhooks + goOAuth.GET("/oauth", h.GoogleChannel.Authorization) // OAuth authorize URL + goOAuth.GET("/callback", h.GoogleChannel.OAuthCallbackGET) // OAuth redirect callback (GET, query params) + goOAuth.POST("/webhooks", h.GoogleChannel.RegisterWebhook) // Register webhook (Google Chat API spaces.watch) + goOAuth.GET("/webhooks", h.GoogleChannel.ListWebhooks) // List webhooks } // Instagram comment management routes (ref: Chatwoot instagram_controller comments) @@ -726,9 +727,9 @@ inboxes.DELETE("/:inbox_id/web_widget", h.WebWidget.DeleteWebWidgetInbox) conversations.POST("/:conversation_id/assign", h.Conversation.AssignAgent) conversations.POST("/:conversation_id/toggle_status", h.Conversation.ToggleStatus) conversations.PATCH("/:conversation_id/labels", h.Label.ReplaceConversationLabels) - conversations.GET("/:conversation_id/labels", h.Label.GetConversationLabels) - conversations.POST("/:conversation_id/labels", h.Label.AddLabelToConversation) - conversations.DELETE("/:conversation_id/labels/:tag_id", h.Label.RemoveLabelFromConversation) + conversations.GET("/:conversation_id/labels", h.Label.GetConversationLabels) + conversations.POST("/:conversation_id/labels", h.Label.AddLabelToConversation) + conversations.DELETE("/:conversation_id/labels/:tag_id", h.Label.RemoveLabelFromConversation) conversations.POST("/:conversation_id/mute", h.Conversation.Mute) conversations.POST("/:conversation_id/unmute", h.Conversation.Unmute) conversations.PATCH("/:conversation_id/priority", h.Conversation.UpdatePriority) @@ -746,15 +747,15 @@ inboxes.DELETE("/:inbox_id/web_widget", h.WebWidget.DeleteWebWidgetInbox) conversations.POST("/:conversation_id/update_last_seen", h.Conversation.UpdateLastSeen) conversations.POST("/:conversation_id/assignments", h.Conversation.AssignTeam) -// Participants nested under conversation - participants := conversations.Group("/:conversation_id/participants") - { - participants.GET("/", h.ConversationParticipant.List) - participants.POST("/", h.ConversationParticipant.Add) - participants.PATCH("/", h.ConversationParticipant.BatchUpdate) - participants.PATCH("/:user_id", h.ConversationParticipant.Update) - participants.DELETE("/:user_id", h.ConversationParticipant.Remove) - } + // Participants nested under conversation + participants := conversations.Group("/:conversation_id/participants") + { + participants.GET("/", h.ConversationParticipant.List) + participants.POST("/", h.ConversationParticipant.Add) + participants.PATCH("/", h.ConversationParticipant.BatchUpdate) + participants.PATCH("/:user_id", h.ConversationParticipant.Update) + participants.DELETE("/:user_id", h.ConversationParticipant.Remove) + } // Draft messages nested under conversation drafts := conversations.Group("/:conversation_id/draft_messages") @@ -774,26 +775,26 @@ inboxes.DELETE("/:inbox_id/web_widget", h.WebWidget.DeleteWebWidgetInbox) msgs.GET("/:message_id", h.Message.Get) msgs.PATCH("/:message_id", h.Message.Update) msgs.DELETE("/:message_id", h.Message.Delete) -msgs.POST("/:message_id/retry", h.Message.Retry) - msgs.POST("/:message_id/translate", h.Message.Translate) + msgs.POST("/:message_id/retry", h.Message.Retry) + msgs.POST("/:message_id/translate", h.Message.Translate) -// Delivery status nested under message - deliveryStatus := msgs.Group("/:message_id/delivery_status") - { - deliveryStatus.GET("/", h.DeliveryStatus.List) - } - } + // Delivery status nested under message + deliveryStatus := msgs.Group("/:message_id/delivery_status") + { + deliveryStatus.GET("/", h.DeliveryStatus.List) + } + } - // WhatsApp calls nested under conversation - whatsappCalls := conversations.Group("/:conversation_id/whatsapp_calls") - { - whatsappCalls.GET("/", h.WhatsAppCall.List) - whatsappCalls.POST("/", h.WhatsAppCall.Create) - whatsappCalls.GET("/:call_id", h.WhatsAppCall.Get) - whatsappCalls.PUT("/:call_id", h.WhatsAppCall.Update) - whatsappCalls.DELETE("/:call_id", h.WhatsAppCall.Delete) + // WhatsApp calls nested under conversation + whatsappCalls := conversations.Group("/:conversation_id/whatsapp_calls") + { + whatsappCalls.GET("/", h.WhatsAppCall.List) + whatsappCalls.POST("/", h.WhatsAppCall.Create) + whatsappCalls.GET("/:call_id", h.WhatsAppCall.Get) + whatsappCalls.PUT("/:call_id", h.WhatsAppCall.Update) + whatsappCalls.DELETE("/:call_id", h.WhatsAppCall.Delete) + } } - } // Tag/Label routes (ref: Chatwoot resources :labels) tags := accountScoped.Group("/tags") @@ -827,7 +828,7 @@ msgs.POST("/:message_id/retry", h.Message.Retry) contacts.GET("/", h.Contact.List) contacts.POST("/", h.Contact.Create) contacts.GET("/search", h.Contact.Search) - contacts.POST("/filter", h.Contact.Filter) + contacts.POST("/filter", h.Contact.Filter) contacts.GET("/:contact_id", h.Contact.Get) contacts.PUT("/:contact_id", h.Contact.Update) contacts.DELETE("/:contact_id", h.Contact.Delete) @@ -838,7 +839,7 @@ msgs.POST("/:message_id/retry", h.Message.Retry) contacts.POST("/import", h.Contact.Import) contacts.GET("/:contact_id/contactable_inboxes", h.Contact.ContactableInboxes) contacts.DELETE("/:contact_id/custom_attributes", h.Contact.DeleteCustomAttributes) - contacts.POST("/:contact_id/destroy_custom_attributes", h.Contact.DestroyCustomAttributes) + contacts.POST("/:contact_id/destroy_custom_attributes", h.Contact.DestroyCustomAttributes) // Contact notes (ref: Chatwoot nested notes under contacts) contacts.GET("/:contact_id/notes", h.Contact.ListNotes) @@ -925,14 +926,14 @@ msgs.POST("/:message_id/retry", h.Message.Retry) agentBots.GET("/:agent_bot_id", h.AgentBot.Get) agentBots.PUT("/:agent_bot_id", h.AgentBot.Update) agentBots.DELETE("/:agent_bot_id", h.AgentBot.Delete) -agentBots.POST("/:agent_bot_id/reset_token", h.AgentBot.ResetToken) - agentBots.POST("/:agent_bot_id/reset_secret", h.AgentBot.ResetSecret) - agentBots.POST("/:agent_bot_id/delete_avatar", h.AgentBot.DeleteAvatar) - // Chatwoot-compatible member action routes (ref: Chatwoot agent_bots member actions) - // Chatwoot: DELETE /accounts/:account_id/agent_bots/:id/avatar - agentBots.DELETE("/:agent_bot_id/avatar", h.AgentBot.DeleteAvatar) - // Chatwoot: POST /accounts/:account_id/agent_bots/:id/reset_access_token - agentBots.POST("/:agent_bot_id/reset_access_token", h.AgentBot.ResetToken) + agentBots.POST("/:agent_bot_id/reset_token", h.AgentBot.ResetToken) + agentBots.POST("/:agent_bot_id/reset_secret", h.AgentBot.ResetSecret) + agentBots.POST("/:agent_bot_id/delete_avatar", h.AgentBot.DeleteAvatar) + // Chatwoot-compatible member action routes (ref: Chatwoot agent_bots member actions) + // Chatwoot: DELETE /accounts/:account_id/agent_bots/:id/avatar + agentBots.DELETE("/:agent_bot_id/avatar", h.AgentBot.DeleteAvatar) + // Chatwoot: POST /accounts/:account_id/agent_bots/:id/reset_access_token + agentBots.POST("/:agent_bot_id/reset_access_token", h.AgentBot.ResetToken) } // Agent Bot-Inbox binding routes @@ -942,7 +943,7 @@ agentBots.POST("/:agent_bot_id/reset_token", h.AgentBot.ResetToken) agentBotInboxes.POST("/", h.AgentBotInbox.Bind) agentBotInboxes.DELETE("/:agent_bot_inbox_id", h.AgentBotInbox.Unbind) agentBotInboxes.PATCH("/:agent_bot_inbox_id/status", h.AgentBotInbox.UpdateStatus) - agentBotInboxes.GET("/", h.AgentBotInbox.ListByInbox) // ?inbox_id=X + agentBotInboxes.GET("/", h.AgentBotInbox.ListByInbox) // ?inbox_id=X agentBotInboxes.GET("/by_bot", h.AgentBotInbox.ListByBot) // ?agent_bot_id=X } @@ -976,17 +977,17 @@ agentBots.POST("/:agent_bot_id/reset_token", h.AgentBot.ResetToken) assistants.POST("/", h.CaptainAssistant.Create) assistants.GET("/:assistant_id", h.CaptainAssistant.Get) assistants.PUT("/:assistant_id", h.CaptainAssistant.Update) - assistants.DELETE("/:assistant_id", h.CaptainAssistant.Delete) + assistants.DELETE("/:assistant_id", h.CaptainAssistant.Delete) - // Inbox bindings - assistants.POST("/:assistant_id/inboxes", h.CaptainAssistant.AssociateInbox) - assistants.DELETE("/:assistant_id/inboxes/:inbox_id", h.CaptainAssistant.DissociateInbox) - // Chatwoot member/collection routes - assistants.GET("/:assistant_id/inboxes", h.CaptainAssistant.GetConfig) - assistants.GET("/tools", h.CaptainAssistant.GetConfig) - assistants.POST("/:assistant_id/playground", h.CaptainAssistant.GenerateResponse) + // Inbox bindings + assistants.POST("/:assistant_id/inboxes", h.CaptainAssistant.AssociateInbox) + assistants.DELETE("/:assistant_id/inboxes/:inbox_id", h.CaptainAssistant.DissociateInbox) + // Chatwoot member/collection routes + assistants.GET("/:assistant_id/inboxes", h.CaptainAssistant.GetConfig) + assistants.GET("/tools", h.CaptainAssistant.GetConfig) + assistants.POST("/:assistant_id/playground", h.CaptainAssistant.GenerateResponse) - // Documents nested under assistant + // Documents nested under assistant assistantDocs := assistants.Group("/:assistant_id/documents") { assistantDocs.GET("/", h.CaptainDocument.List) @@ -1013,55 +1014,55 @@ agentBots.POST("/:agent_bot_id/reset_token", h.AgentBot.ResetToken) customTools.POST("/", h.CaptainCustomTool.Create) customTools.GET("/:tool_id", h.CaptainCustomTool.Get) customTools.PUT("/:tool_id", h.CaptainCustomTool.Update) - customTools.DELETE("/:tool_id", h.CaptainCustomTool.Delete) + customTools.DELETE("/:tool_id", h.CaptainCustomTool.Delete) - // Test custom tool before enabling - customTools.POST("/test", h.CaptainCustomTool.TestTool) - } - - // Flat document routes (Chatwoot: resources :documents, only: [:index, :show, :create, :destroy]) - documents := captain.Group("/documents") - { - documents.GET("/", h.CaptainDocument.List) - documents.POST("/", h.CaptainDocument.Create) - documents.GET("/:document_id", h.CaptainDocument.Get) - documents.DELETE("/:document_id", h.CaptainDocument.Delete) - documents.POST("/:document_id/sync", h.CaptainDocument.SyncDocument) - } - - // Flat scenario routes (Chatwoot: resources :scenarios) - scenarios := captain.Group("/scenarios") - { - scenarios.GET("/", h.CaptainScenario.List) - scenarios.POST("/", h.CaptainScenario.Create) - scenarios.GET("/:scenario_id", h.CaptainScenario.Get) - scenarios.PUT("/:scenario_id", h.CaptainScenario.Update) - scenarios.DELETE("/:scenario_id", h.CaptainScenario.Delete) - } - - // Copilot features (ref: Chatwoot Captain::Copilot) - copilotThreads := captain.Group("/copilot_threads") - { - copilotThreads.GET("/", h.Copilot.ListThreads) - copilotThreads.POST("/", h.Copilot.CreateThread) - copilotThreads.GET("/:thread_id", h.Copilot.GetThread) - copilotThreads.DELETE("/:thread_id", h.Copilot.DeleteThread) - // Nested copilot_messages (Chatwoot: resources :copilot_messages, only: [:index, :create]) - copilotThreadMessages := copilotThreads.Group("/:thread_id/copilot_messages") - { - copilotThreadMessages.GET("/", h.Copilot.ListSuggestionMessages) - copilotThreadMessages.POST("/", h.Copilot.SendMessage) + // Test custom tool before enabling + customTools.POST("/test", h.CaptainCustomTool.TestTool) } - copilotThreads.POST("/:thread_id/messages", h.Copilot.SendMessage) - } - // Copilot suggestion messages (conversation-level) - // GET/POST /api/v1/accounts/:account_id/copilot_messages - copilotMessages := captain.Group("/copilot_messages") - { - copilotMessages.GET("/", h.Copilot.ListSuggestionMessages) - copilotMessages.POST("/", h.Copilot.CreateSuggestionMessage) - } + // Flat document routes (Chatwoot: resources :documents, only: [:index, :show, :create, :destroy]) + documents := captain.Group("/documents") + { + documents.GET("/", h.CaptainDocument.List) + documents.POST("/", h.CaptainDocument.Create) + documents.GET("/:document_id", h.CaptainDocument.Get) + documents.DELETE("/:document_id", h.CaptainDocument.Delete) + documents.POST("/:document_id/sync", h.CaptainDocument.SyncDocument) + } + + // Flat scenario routes (Chatwoot: resources :scenarios) + scenarios := captain.Group("/scenarios") + { + scenarios.GET("/", h.CaptainScenario.List) + scenarios.POST("/", h.CaptainScenario.Create) + scenarios.GET("/:scenario_id", h.CaptainScenario.Get) + scenarios.PUT("/:scenario_id", h.CaptainScenario.Update) + scenarios.DELETE("/:scenario_id", h.CaptainScenario.Delete) + } + + // Copilot features (ref: Chatwoot Captain::Copilot) + copilotThreads := captain.Group("/copilot_threads") + { + copilotThreads.GET("/", h.Copilot.ListThreads) + copilotThreads.POST("/", h.Copilot.CreateThread) + copilotThreads.GET("/:thread_id", h.Copilot.GetThread) + copilotThreads.DELETE("/:thread_id", h.Copilot.DeleteThread) + // Nested copilot_messages (Chatwoot: resources :copilot_messages, only: [:index, :create]) + copilotThreadMessages := copilotThreads.Group("/:thread_id/copilot_messages") + { + copilotThreadMessages.GET("/", h.Copilot.ListSuggestionMessages) + copilotThreadMessages.POST("/", h.Copilot.SendMessage) + } + copilotThreads.POST("/:thread_id/messages", h.Copilot.SendMessage) + } + + // Copilot suggestion messages (conversation-level) + // GET/POST /api/v1/accounts/:account_id/copilot_messages + copilotMessages := captain.Group("/copilot_messages") + { + copilotMessages.GET("/", h.Copilot.ListSuggestionMessages) + copilotMessages.POST("/", h.Copilot.CreateSuggestionMessage) + } // Copilot AI actions captain.POST("/copilot/suggest_replies", h.Copilot.GetSuggestedReplies) @@ -1072,62 +1073,62 @@ agentBots.POST("/:agent_bot_id/reset_token", h.AgentBot.ResetToken) // Reference: Chatwoot Captain::TasksController tasks := captain.Group("/tasks") { -tasks.POST("/reply_suggestion", h.CaptainTask.ReplySuggestion) - tasks.POST("/summarize", h.CaptainTask.Summarize) - tasks.POST("/rewrite", h.CaptainTask.Rewrite) - // M12: SSE streaming variants of Captain task endpoints - tasks.POST("/reply_suggestion/stream", h.CaptainTask.StreamReplySuggestion) - tasks.POST("/summarize/stream", h.CaptainTask.StreamSummarize) - tasks.POST("/rewrite/stream", h.CaptainTask.StreamRewrite) - } + tasks.POST("/reply_suggestion", h.CaptainTask.ReplySuggestion) + tasks.POST("/summarize", h.CaptainTask.Summarize) + tasks.POST("/rewrite", h.CaptainTask.Rewrite) + // M12: SSE streaming variants of Captain task endpoints + tasks.POST("/reply_suggestion/stream", h.CaptainTask.StreamReplySuggestion) + tasks.POST("/summarize/stream", h.CaptainTask.StreamSummarize) + tasks.POST("/rewrite/stream", h.CaptainTask.StreamRewrite) + } - // M12: SSE streaming for Copilot messages - captain.GET("/copilot/stream", h.SSEStream.StreamCopilotMessage) + // M12: SSE streaming for Copilot messages + captain.GET("/copilot/stream", h.SSEStream.StreamCopilotMessage) - // M12: Conversation insight endpoints - // Reference: Chatwoot Captain::ConversationInsightController - insights := captain.Group("/conversation_insights") - { - insights.POST("/:conversation_id/analyze_participants", h.ConversationInsight.AnalyzeParticipants) - insights.POST("/:conversation_id/extract_action_items", h.ConversationInsight.ExtractActionItems) - insights.POST("/:conversation_id/suggest_labels", h.ConversationInsight.SuggestLabels) - } + // M12: Conversation insight endpoints + // Reference: Chatwoot Captain::ConversationInsightController + insights := captain.Group("/conversation_insights") + { + insights.POST("/:conversation_id/analyze_participants", h.ConversationInsight.AnalyzeParticipants) + insights.POST("/:conversation_id/extract_action_items", h.ConversationInsight.ExtractActionItems) + insights.POST("/:conversation_id/suggest_labels", h.ConversationInsight.SuggestLabels) + } - // M12: Captain Preferences (per-account AI configuration) - // Reference: Chatwoot Captain::PreferencesController - preferences := captain.Group("/preferences") - { - preferences.POST("/", h.CaptainPreference.Create) - preferences.GET("/", h.CaptainPreference.Get) - preferences.PUT("/", h.CaptainPreference.Update) - preferences.DELETE("/", h.CaptainPreference.Delete) - } + // M12: Captain Preferences (per-account AI configuration) + // Reference: Chatwoot Captain::PreferencesController + preferences := captain.Group("/preferences") + { + preferences.POST("/", h.CaptainPreference.Create) + preferences.GET("/", h.CaptainPreference.Get) + preferences.PUT("/", h.CaptainPreference.Update) + preferences.DELETE("/", h.CaptainPreference.Delete) + } - // M12: Label Suggestion + Follow Up (GET endpoints) - // Reference: Chatwoot Captain::ConversationInsightController - tasks.GET("/label_suggestion", h.CaptainTaskExtended.LabelSuggestion) - tasks.GET("/follow_up", h.CaptainTaskExtended.FollowUp) + // M12: Label Suggestion + Follow Up (GET endpoints) + // Reference: Chatwoot Captain::ConversationInsightController + tasks.GET("/label_suggestion", h.CaptainTaskExtended.LabelSuggestion) + tasks.GET("/follow_up", h.CaptainTaskExtended.FollowUp) - // M12: Assistant Response generation (single conversation) - // Reference: Chatwoot Captain::AssistantResponsesController - assistantResponses := captain.Group("/assistant_responses") - { - // Chatwoot: resources :assistant_responses (standard CRUD) - assistantResponses.POST("/", h.CaptainAssistantResponse.Create) - assistantResponses.GET("/", h.CaptainAssistantResponse.List) - assistantResponses.GET("/:response_id", h.CaptainAssistantResponse.Get) - assistantResponses.PUT("/:response_id", h.CaptainAssistantResponse.Update) - assistantResponses.DELETE("/:response_id", h.CaptainAssistantResponse.Delete) - // Extended: ProcessResponse (AI generation + optional store) - assistantResponses.POST("/process", h.CaptainAssistantResponse.ProcessResponse) - } + // M12: Assistant Response generation (single conversation) + // Reference: Chatwoot Captain::AssistantResponsesController + assistantResponses := captain.Group("/assistant_responses") + { + // Chatwoot: resources :assistant_responses (standard CRUD) + assistantResponses.POST("/", h.CaptainAssistantResponse.Create) + assistantResponses.GET("/", h.CaptainAssistantResponse.List) + assistantResponses.GET("/:response_id", h.CaptainAssistantResponse.Get) + assistantResponses.PUT("/:response_id", h.CaptainAssistantResponse.Update) + assistantResponses.DELETE("/:response_id", h.CaptainAssistantResponse.Delete) + // Extended: ProcessResponse (AI generation + optional store) + assistantResponses.POST("/process", h.CaptainAssistantResponse.ProcessResponse) + } - // M12: Bulk AI actions (batch operations on multiple conversations) - // Reference: Chatwoot Captain::BulkActionsController - bulkActions := captain.Group("/bulk_actions") - { - bulkActions.POST("/", h.CaptainBulkAction.Execute) - } + // M12: Bulk AI actions (batch operations on multiple conversations) + // Reference: Chatwoot Captain::BulkActionsController + bulkActions := captain.Group("/bulk_actions") + { + bulkActions.POST("/", h.CaptainBulkAction.Execute) + } } // Draft messages — account-scoped search & count @@ -1179,8 +1180,8 @@ tasks.POST("/reply_suggestion", h.CaptainTask.ReplySuggestion) // Dashboard apps (custom dashboards) dashboardApps := accountScoped.Group("/dashboard_apps") { - dashboardApps.GET("", h.DashboardApp.List) - dashboardApps.POST("", h.DashboardApp.Create) + dashboardApps.GET("", h.DashboardApp.List) + dashboardApps.POST("", h.DashboardApp.Create) dashboardApps.GET("/search", h.DashboardApp.Search) dashboardApps.GET("/:dashboard_app_id", h.DashboardApp.Get) dashboardApps.PUT("/:dashboard_app_id", h.DashboardApp.Update) @@ -1227,9 +1228,9 @@ tasks.POST("/reply_suggestion", h.CaptainTask.ReplySuggestion) articles.GET("/search", h.Article.Search) articles.GET("/status_counts", h.Article.StatusCounts) articles.POST("/reorder", h.Article.Reorder) - articles.POST("/bulk_update_status", h.Article.BulkUpdateStatus) - articles.POST("/bulk_delete", h.Article.BulkDelete) - articles.POST("/bulk_actions", h.Article.BulkActions) + articles.POST("/bulk_update_status", h.Article.BulkUpdateStatus) + articles.POST("/bulk_delete", h.Article.BulkDelete) + articles.POST("/bulk_actions", h.Article.BulkActions) articles.GET("/:article_id", h.Article.Get) articles.GET("/:article_id/edit", h.Article.Edit) articles.PUT("/:article_id", h.Article.Update) @@ -1263,13 +1264,13 @@ tasks.POST("/reply_suggestion", h.CaptainTask.ReplySuggestion) // Reference: Chatwoot namespace :automation_rules automationRules := accountScoped.Group("/automation_rules") { - automationRules.GET("/", h.AutomationRule.List) - automationRules.POST("/", h.AutomationRule.Create) - automationRules.GET("/:automation_id", h.AutomationRule.Get) - automationRules.PUT("/:automation_id", h.AutomationRule.Update) - automationRules.DELETE("/:automation_id", h.AutomationRule.Delete) - automationRules.POST("/:automation_id/clone", h.AutomationRule.Clone) - automationRules.POST("/:automation_id/toggle_active", h.AutomationRule.ToggleActive) + automationRules.GET("/", h.AutomationRule.List) + automationRules.POST("/", h.AutomationRule.Create) + automationRules.GET("/:automation_id", h.AutomationRule.Get) + automationRules.PUT("/:automation_id", h.AutomationRule.Update) + automationRules.DELETE("/:automation_id", h.AutomationRule.Delete) + automationRules.POST("/:automation_id/clone", h.AutomationRule.Clone) + automationRules.POST("/:automation_id/toggle_active", h.AutomationRule.ToggleActive) } // Macros — CRUD + execute @@ -1306,7 +1307,7 @@ tasks.POST("/reply_suggestion", h.CaptainTask.ReplySuggestion) // Reference: Chatwoot namespace :csat_survey_responses csats := accountScoped.Group("/csat_survey_responses") { -csats.GET("/", h.CsatSurvey.List) + csats.GET("/", h.CsatSurvey.List) csats.GET("/metrics", h.CsatSurvey.Metrics) csats.GET("/download", h.CsatSurvey.Download) csats.PATCH("/:id", h.CsatSurvey.Update) @@ -1330,8 +1331,8 @@ csats.GET("/", h.CsatSurvey.List) // Reference: Chatwoot resource :notification_settings, only: [:show, :update] notifSettings := accountScoped.Group("/notification_settings") { - notifSettings.GET("/", h.NotificationSetting.Show) - notifSettings.PATCH("/", h.NotificationSetting.Update) + notifSettings.GET("/", h.NotificationSetting.Show) + notifSettings.PATCH("/", h.NotificationSetting.Update) } // Notification subscriptions (account-scoped) @@ -1342,7 +1343,7 @@ csats.GET("/", h.CsatSurvey.List) notifSubs.DELETE("/:identifier", h.NotificationSubscription.Destroy) } -// Canned Responses — CRUD + search + // Canned Responses — CRUD + search // Reference: Chatwoot namespace :canned_responses cannedResponses := accountScoped.Group("/canned_responses") { @@ -1465,7 +1466,7 @@ csats.GET("/", h.CsatSurvey.List) customAttrDefs.POST("/", h.CustomAttributeDefinition.Create) customAttrDefs.GET("/:id", h.CustomAttributeDefinition.Get) customAttrDefs.PUT("/:id", h.CustomAttributeDefinition.Update) - customAttrDefs.PATCH("/:id", h.CustomAttributeDefinition.Update) // Chatwoot uses PATCH + customAttrDefs.PATCH("/:id", h.CustomAttributeDefinition.Update) // Chatwoot uses PATCH customAttrDefs.DELETE("/:id", h.CustomAttributeDefinition.Delete) } @@ -1493,7 +1494,7 @@ csats.GET("/", h.CsatSurvey.List) customFilters.POST("/", h.CustomFilter.Create) customFilters.GET("/:id", h.CustomFilter.Get) customFilters.PUT("/:id", h.CustomFilter.Update) - customFilters.PATCH("/:id", h.CustomFilter.Update) // Chatwoot uses PATCH + customFilters.PATCH("/:id", h.CustomFilter.Update) // Chatwoot uses PATCH customFilters.DELETE("/:id", h.CustomFilter.Delete) } } @@ -1586,7 +1587,6 @@ func registerPlatformTokenRoutes(g *gin.RouterGroup, h *Handlers) { // Platform Account Users — AccessToken authenticated // Reference: Chatwoot Platform::Api::V1::AccountUsersController (nested under accounts) - // Gin wildcard must use :id (same as accounts/:id) — no :account_id allowed in same prefix g.GET("/accounts/:account_id/account_users", h.PlatformAccountUser.Index) g.POST("/accounts/:account_id/account_users", h.PlatformAccountUser.Create) g.DELETE("/accounts/:account_id/account_users/:user_id", h.PlatformAccountUser.Destroy) diff --git a/internal/service/contact_service.go b/internal/service/contact_service.go index 129800b7..b92291a9 100644 --- a/internal/service/contact_service.go +++ b/internal/service/contact_service.go @@ -30,6 +30,11 @@ func NewContactService(repo *repository.ContactRepo, contactInboxSvc *ContactInb return &ContactService{repo: repo, contactInboxSvc: contactInboxSvc, noteRepo: noteRepo} } +// Ready reports whether the service has the dependencies required for DB-backed operations. +func (s *ContactService) Ready() bool { + return s != nil && s.repo != nil +} + // ListByAccount retrieves all contacts for an account with optional sort. func (s *ContactService) ListByAccount(ctx context.Context, accountID uint, offset, limit int, sort string) ([]model.Contact, int64, error) { return s.repo.FindByAccount(ctx, accountID, offset, limit, sort) @@ -62,21 +67,21 @@ func (s *ContactService) ListContactInboxes(ctx context.Context, contactID uint) // Reference: Chatwoot app/controllers/api/v1/contacts_controller.rb#create // When inbox_id is provided, a ContactInbox record is auto-created (Chatwoot pattern). type CreateContactRequest struct { - Name string `json:"name" validate:"required,min=1"` - Email string `json:"email,omitempty" validate:"omitempty,email"` - Phone string `json:"phone,omitempty"` - Identifier string `json:"identifier,omitempty"` - AvatarURL string `json:"avatar_url,omitempty"` - InboxID *uint `json:"inbox_id,omitempty"` - SourceID string `json:"source_id,omitempty"` + Name string `json:"name" validate:"required,min=1"` + Email string `json:"email,omitempty" validate:"omitempty,email"` + Phone string `json:"phone,omitempty"` + Identifier string `json:"identifier,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + InboxID *uint `json:"inbox_id,omitempty"` + SourceID string `json:"source_id,omitempty"` AdditionalAttributes *model.JSONMap `json:"additional_attributes,omitempty"` CustomAttributes *model.JSONMap `json:"custom_attributes,omitempty"` - ContactType string `json:"contact_type,omitempty"` - MiddleName string `json:"middle_name,omitempty"` - LastName string `json:"last_name,omitempty"` - CountryCode string `json:"country_code,omitempty"` - Location string `json:"location,omitempty"` - CompanyID *uint `json:"company_id,omitempty"` + ContactType string `json:"contact_type,omitempty"` + MiddleName string `json:"middle_name,omitempty"` + LastName string `json:"last_name,omitempty"` + CountryCode string `json:"country_code,omitempty"` + Location string `json:"location,omitempty"` + CompanyID *uint `json:"company_id,omitempty"` } // Create creates a new contact and optionally auto-creates a ContactInbox when inbox_id is provided. @@ -87,19 +92,19 @@ func (s *ContactService) Create(ctx context.Context, accountID uint, req CreateC } contact := &model.Contact{ - AccountID: accountID, - Name: req.Name, - Email: req.Email, - PhoneNumber: req.Phone, - Identifier: req.Identifier, - AvatarURL: req.AvatarURL, - MiddleName: req.MiddleName, - LastName: req.LastName, - CountryCode: req.CountryCode, - Location: req.Location, - ContactType: req.ContactType, - SourceID: req.SourceID, - CompanyID: req.CompanyID, + AccountID: accountID, + Name: req.Name, + Email: req.Email, + PhoneNumber: req.Phone, + Identifier: req.Identifier, + AvatarURL: req.AvatarURL, + MiddleName: req.MiddleName, + LastName: req.LastName, + CountryCode: req.CountryCode, + Location: req.Location, + ContactType: req.ContactType, + SourceID: req.SourceID, + CompanyID: req.CompanyID, } if req.AdditionalAttributes != nil { @@ -136,19 +141,19 @@ func (s *ContactService) Create(ctx context.Context, accountID uint, req CreateC // UpdateContactRequest is the DTO for updating a contact. type UpdateContactRequest struct { - Name string `json:"name,omitempty" validate:"omitempty,min=1"` - Email string `json:"email,omitempty" validate:"omitempty,email"` - Phone string `json:"phone,omitempty"` - Identifier string `json:"identifier,omitempty"` - AvatarURL string `json:"avatar_url,omitempty"` - MiddleName string `json:"middle_name,omitempty"` - LastName string `json:"last_name,omitempty"` - CountryCode string `json:"country_code,omitempty"` - Location string `json:"location,omitempty"` - ContactType string `json:"contact_type,omitempty"` + Name string `json:"name,omitempty" validate:"omitempty,min=1"` + Email string `json:"email,omitempty" validate:"omitempty,email"` + Phone string `json:"phone,omitempty"` + Identifier string `json:"identifier,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + MiddleName string `json:"middle_name,omitempty"` + LastName string `json:"last_name,omitempty"` + CountryCode string `json:"country_code,omitempty"` + Location string `json:"location,omitempty"` + ContactType string `json:"contact_type,omitempty"` AdditionalAttributes *model.JSONMap `json:"additional_attributes,omitempty"` CustomAttributes *model.JSONMap `json:"custom_attributes,omitempty"` - CompanyID *uint `json:"company_id,omitempty"` + CompanyID *uint `json:"company_id,omitempty"` } // Update modifies an existing contact. @@ -419,9 +424,9 @@ func (s *ContactService) DeleteCustomAttributes(ctx context.Context, accountID, // ContactableInbox represents an inbox that a contact can be associated with. // Reference: Chatwoot contacts#contactable_inboxes type ContactableInbox struct { - Inbox model.Inbox `json:"inbox"` - ContactInbox *model.ContactInbox `json:"contact_inbox,omitempty"` - SourceID string `json:"source_id,omitempty"` + Inbox model.Inbox `json:"inbox"` + ContactInbox *model.ContactInbox `json:"contact_inbox,omitempty"` + SourceID string `json:"source_id,omitempty"` } // GetContactableInboxes returns inboxes that a contact can be added to. @@ -451,4 +456,4 @@ func (s *ContactService) GetContactableInboxes(ctx context.Context, accountID, c } return result, nil -} \ No newline at end of file +} diff --git a/internal/service/draft_message_service.go b/internal/service/draft_message_service.go index aa9dedae..3a6c9347 100644 --- a/internal/service/draft_message_service.go +++ b/internal/service/draft_message_service.go @@ -21,6 +21,11 @@ func NewDraftMessageService(repo *repository.DraftMessageRepo, conversationRepo return &DraftMessageService{repo: repo, conversationRepo: conversationRepo} } +// Ready reports whether the service has its repository configured. +func (s *DraftMessageService) Ready() bool { + return s != nil && s.repo != nil +} + // List retrieves all draft messages for a conversation, optionally filtered by user. func (s *DraftMessageService) List(ctx context.Context, accountID, conversationID, userID uint) ([]model.DraftMessage, error) { // Verify conversation belongs to the account @@ -101,4 +106,4 @@ func (s *DraftMessageService) Search(ctx context.Context, accountID uint, query // Count returns the total number of draft messages for an account. func (s *DraftMessageService) Count(ctx context.Context, accountID uint) (int64, error) { return s.repo.CountByAccount(ctx, accountID) -} \ No newline at end of file +} diff --git a/internal/service/inbox_service.go b/internal/service/inbox_service.go index 1ffa728d..3ac8e430 100644 --- a/internal/service/inbox_service.go +++ b/internal/service/inbox_service.go @@ -9,10 +9,10 @@ import ( "fmt" "github.com/gochat/gochat/internal/campaign" - "github.com/gochat/gochat/internal/model" - "github.com/gochat/gochat/internal/repository" - channelmodel "github.com/gochat/gochat/internal/model/channel" whatsapp "github.com/gochat/gochat/internal/channel/whatsapp" + "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" + "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" pkgvalidator "github.com/gochat/gochat/pkg/validator" ) @@ -20,13 +20,13 @@ import ( // InboxService implements business logic for Inbox operations. // Reference: Chatwoot app/controllers/api/v1/inboxes_controller.rb type InboxService struct { - repo *repository.InboxRepo - agentBotInboxRepo *repository.AgentBotInboxRepo - agentBotRepo *repository.AgentBotRepo - campaignRepo *repository.CampaignRepo - webhookSubRepo *repository.WebhookSubscriptionRepo - whatsappService *whatsapp.WhatsAppService - whatsappRepo *whatsapp.Repository + repo *repository.InboxRepo + agentBotInboxRepo *repository.AgentBotInboxRepo + agentBotRepo *repository.AgentBotRepo + campaignRepo *repository.CampaignRepo + webhookSubRepo *repository.WebhookSubscriptionRepo + whatsappService *whatsapp.WhatsAppService + whatsappRepo *whatsapp.Repository } // NewInboxService creates a new Inbox service. @@ -40,16 +40,21 @@ func NewInboxService( whatsappRepo *whatsapp.Repository, ) *InboxService { return &InboxService{ - repo: repo, - agentBotInboxRepo: agentBotInboxRepo, - agentBotRepo: agentBotRepo, - campaignRepo: campaignRepo, - webhookSubRepo: webhookSubRepo, - whatsappService: whatsappService, - whatsappRepo: whatsappRepo, + repo: repo, + agentBotInboxRepo: agentBotInboxRepo, + agentBotRepo: agentBotRepo, + campaignRepo: campaignRepo, + webhookSubRepo: webhookSubRepo, + whatsappService: whatsappService, + whatsappRepo: whatsappRepo, } } +// Ready reports whether the service has its primary repository configured. +func (s *InboxService) Ready() bool { + return s != nil && s.repo != nil +} + // ListByAccount retrieves all inboxes for an account. func (s *InboxService) ListByAccount(ctx context.Context, accountID uint, offset, limit int) ([]model.Inbox, int64, error) { return s.repo.FindByAccount(ctx, accountID, offset, limit) @@ -67,10 +72,10 @@ func (s *InboxService) GetByAccountAndID(ctx context.Context, accountID, id uint // CreateInboxRequest is the DTO for creating an inbox. type CreateInboxRequest struct { - Name string `json:"name" validate:"required,min=2"` - ChannelType string `json:"channel_type" validate:"required,oneof=web_widget telegram facebook instagram whatsapp email api tiktok line twilio_sms"` - Enabled bool `json:"enabled"` - EnableAutoAssignment bool `json:"enable_auto_assignment"` + Name string `json:"name" validate:"required,min=2"` + ChannelType string `json:"channel_type" validate:"required,oneof=web_widget telegram facebook instagram whatsapp email api tiktok line twilio_sms"` + Enabled bool `json:"enabled"` + EnableAutoAssignment bool `json:"enable_auto_assignment"` } // Create creates a new inbox. @@ -89,10 +94,10 @@ func (s *InboxService) Create(ctx context.Context, accountID uint, req CreateInb } inbox := &model.Inbox{ - AccountID: accountID, - Name: req.Name, - ChannelType: req.ChannelType, - Enabled: req.Enabled, + AccountID: accountID, + Name: req.Name, + ChannelType: req.ChannelType, + Enabled: req.Enabled, EnableAutoAssignment: req.EnableAutoAssignment, } @@ -109,9 +114,9 @@ func (s *InboxService) Create(ctx context.Context, accountID uint, req CreateInb // UpdateInboxRequest is the DTO for updating an inbox. type UpdateInboxRequest struct { - Name string `json:"name,omitempty" validate:"omitempty,min=2"` - Enabled *bool `json:"enabled,omitempty"` - EnableAutoAssignment *bool `json:"enable_auto_assignment,omitempty"` + Name string `json:"name,omitempty" validate:"omitempty,min=2"` + Enabled *bool `json:"enabled,omitempty"` + EnableAutoAssignment *bool `json:"enable_auto_assignment,omitempty"` } // Update modifies an existing inbox. @@ -169,7 +174,7 @@ type WebWidgetConfig struct { WelcomeTagline string `json:"welcome_tagline,omitempty"` GreetingEnabled bool `json:"greeting_enabled,omitempty"` GreetingMessage string `json:"greeting_message,omitempty"` - ReplyTime string `json:"reply_time,omitempty"` // "a_few_minutes", "a_few_hours", "in_a_day" + ReplyTime string `json:"reply_time,omitempty"` // "a_few_minutes", "a_few_hours", "in_a_day" PreChatMessage string `json:"pre_chat_message,omitempty"` PreChatFieldsEnabled bool `json:"pre_chat_fields_enabled,omitempty"` AutoAssignmentEnabled bool `json:"auto_assignment_enabled,omitempty"` @@ -386,10 +391,10 @@ type TelegramInboxConfig struct { // CreateTelegramInboxRequest is the DTO for creating a telegram inbox. // Reference: Chatwoot TelegramBotsController#create type CreateTelegramInboxRequest struct { - Name string `json:"name" validate:"required,min=2"` - BotToken string `json:"bot_token" validate:"required"` - WelcomeMessage string `json:"welcome_message,omitempty"` - EnableAutoAssignment bool `json:"enable_auto_assignment,omitempty"` + Name string `json:"name" validate:"required,min=2"` + BotToken string `json:"bot_token" validate:"required"` + WelcomeMessage string `json:"welcome_message,omitempty"` + EnableAutoAssignment bool `json:"enable_auto_assignment,omitempty"` } // UpdateTelegramInboxRequest is the DTO for updating a telegram inbox. @@ -643,31 +648,31 @@ func (s *InboxService) UpdateTelegramConfig(ctx context.Context, accountID, inbo // InstagramInboxConfig represents the channel_config JSON structure for an Instagram inbox. // This stores the IG-specific fields alongside the ChannelInstagram table record. type InstagramInboxConfig struct { - InstagramAccountID string `json:"instagram_account_id"` + InstagramAccountID string `json:"instagram_account_id"` InstagramBusinessAccountID string `json:"instagram_business_account_id,omitempty"` - PageAccessToken string `json:"page_access_token"` - ConnectedFBPageID string `json:"connected_fb_page_id"` - InstagramAccountName string `json:"instagram_account_name,omitempty"` + PageAccessToken string `json:"page_access_token"` + ConnectedFBPageID string `json:"connected_fb_page_id"` + InstagramAccountName string `json:"instagram_account_name,omitempty"` } // CreateInstagramInboxRequest is the DTO for creating an Instagram inbox. // Reference: Chatwoot inboxes#create with channel_type=instagram type CreateInstagramInboxRequest struct { - Name string `json:"name" validate:"required,min=2"` - InstagramAccountID string `json:"instagram_account_id" validate:"required"` - PageAccessToken string `json:"page_access_token" validate:"required"` - ConnectedFBPageID string `json:"connected_fb_page_id" validate:"required"` + Name string `json:"name" validate:"required,min=2"` + InstagramAccountID string `json:"instagram_account_id" validate:"required"` + PageAccessToken string `json:"page_access_token" validate:"required"` + ConnectedFBPageID string `json:"connected_fb_page_id" validate:"required"` InstagramBusinessAccountID string `json:"instagram_business_account_id,omitempty"` - InstagramAccountName string `json:"instagram_account_name,omitempty"` - EnableAutoAssignment bool `json:"enable_auto_assignment,omitempty"` + InstagramAccountName string `json:"instagram_account_name,omitempty"` + EnableAutoAssignment bool `json:"enable_auto_assignment,omitempty"` } // UpdateInstagramInboxRequest is the DTO for updating an Instagram inbox. type UpdateInstagramInboxRequest struct { - Name *string `json:"name,omitempty" validate:"omitempty,min=2"` - InstagramAccountName *string `json:"instagram_account_name,omitempty"` - Enabled *bool `json:"enabled,omitempty"` - EnableAutoAssignment *bool `json:"enable_auto_assignment,omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty,min=2"` + InstagramAccountName *string `json:"instagram_account_name,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + EnableAutoAssignment *bool `json:"enable_auto_assignment,omitempty"` } // CreateInstagramInbox creates a new Instagram channel inbox. @@ -683,22 +688,22 @@ func (s *InboxService) CreateInstagramInbox(ctx context.Context, accountID uint, // Create the ChannelInstagram record ch := &channelmodel.ChannelInstagram{ - AccountID: accountID, - InstagramAccountID: req.InstagramAccountID, + AccountID: accountID, + InstagramAccountID: req.InstagramAccountID, InstagramBusinessAccountID: req.InstagramBusinessAccountID, - PageAccessToken: req.PageAccessToken, - ConnectedFBPageID: req.ConnectedFBPageID, - InstagramAccountName: req.InstagramAccountName, - ReauthorizationRequired: false, + PageAccessToken: req.PageAccessToken, + ConnectedFBPageID: req.ConnectedFBPageID, + InstagramAccountName: req.InstagramAccountName, + ReauthorizationRequired: false, } // Serialize the Instagram config into ChannelConfig JSON config := InstagramInboxConfig{ - InstagramAccountID: req.InstagramAccountID, + InstagramAccountID: req.InstagramAccountID, InstagramBusinessAccountID: req.InstagramBusinessAccountID, - PageAccessToken: req.PageAccessToken, - ConnectedFBPageID: req.ConnectedFBPageID, - InstagramAccountName: req.InstagramAccountName, + PageAccessToken: req.PageAccessToken, + ConnectedFBPageID: req.ConnectedFBPageID, + InstagramAccountName: req.InstagramAccountName, } configJSON, err := json.Marshal(config) if err != nil { @@ -897,11 +902,11 @@ func (s *InboxService) CreateFacebookInbox(ctx context.Context, accountID uint, // Create the ChannelFacebook record ch := &channelmodel.ChannelFacebook{ - AccountID: accountID, - PageID: req.PageID, - PageAccessToken: req.PageAccessToken, - PageName: req.PageName, - WebhookVerifyToken: verifyToken, + AccountID: accountID, + PageID: req.PageID, + PageAccessToken: req.PageAccessToken, + PageName: req.PageName, + WebhookVerifyToken: verifyToken, ReauthorizationRequired: false, } @@ -1066,11 +1071,11 @@ func (s *InboxService) GetAgentBot(ctx context.Context, accountID, inboxID uint) // InboxHealthResult represents the health check result for an inbox. // Reference: Chatwoot InboxesController#health (GET member action) type InboxHealthResult struct { - InboxID uint `json:"inbox_id"` + InboxID uint `json:"inbox_id"` ChannelType string `json:"channel_type"` - Healthy bool `json:"healthy"` - Status string `json:"status"` // "connected", "disconnected", "misconfigured", "unknown" - Details string `json:"details,omitempty"` + Healthy bool `json:"healthy"` + Status string `json:"status"` // "connected", "disconnected", "misconfigured", "unknown" + Details string `json:"details,omitempty"` } // Health checks the health status of an inbox's channel connection. @@ -1193,7 +1198,7 @@ func (s *InboxService) SyncTemplates(ctx context.Context, accountID, inboxID uin // RegisterWebhookRequest represents the request body for registering a webhook on an inbox. // Reference: Chatwoot InboxesController#register_webhook (POST member action) type RegisterWebhookRequest struct { - URL string `json:"url" validate:"required,url"` + URL string `json:"url" validate:"required,url"` Events []string `json:"events,omitempty"` // e.g. ["message_created", "conversation_updated"] } @@ -1378,4 +1383,4 @@ func generateRandomHex(n int) (string, error) { return "", err } return hex.EncodeToString(b), nil -} \ No newline at end of file +} diff --git a/internal/service/integration_hook_service.go b/internal/service/integration_hook_service.go index 40c15054..247ee786 100644 --- a/internal/service/integration_hook_service.go +++ b/internal/service/integration_hook_service.go @@ -27,6 +27,11 @@ func NewIntegrationHookService(hookRepo *repository.IntegrationHookRepo, appRepo return &IntegrationHookService{hookRepo: hookRepo, appRepo: appRepo, registry: registry} } +// Ready reports whether the service has its repositories configured. +func (s *IntegrationHookService) Ready() bool { + return s != nil && s.hookRepo != nil && s.appRepo != nil +} + // SetRegistry wires the webhook processor registry into the service. // Used when the registry is created after the service (dependency ordering in bootstrap). func (s *IntegrationHookService) SetRegistry(registry *WebhookProcessorRegistry) { @@ -36,10 +41,10 @@ func (s *IntegrationHookService) SetRegistry(registry *WebhookProcessorRegistry) // CreateHookRequest is the DTO for creating an integration hook. // Reference: Chatwoot HooksController#create — params: {hook_type, url, inbox_id, settings} type CreateHookRequest struct { - HookType string `json:"hook_type" validate:"required,oneof=webhook slack shopify linear notion"` - URL string `json:"url,omitempty" validate:"omitempty,url"` - InboxID *uint `json:"inbox_id,omitempty"` - Settings map[string]interface{} `json:"settings,omitempty"` + HookType string `json:"hook_type" validate:"required,oneof=webhook slack shopify linear notion"` + URL string `json:"url,omitempty" validate:"omitempty,url"` + InboxID *uint `json:"inbox_id,omitempty"` + Settings map[string]interface{} `json:"settings,omitempty"` } // UpdateHookRequest is the DTO for updating an integration hook.